---
title: "Content (No Build)"
description: "How pitlane/content turns Markdown, MDX, and data files into schema-validated collections a Remix 3 controller can query, served straight from source with Sätteri rendering at request time."
url: "https://pitlane.tools/guides/content-no-build"
buildMode: "no-build"
counterpart: "https://pitlane.tools/guides/content.md"
---

# Content (No Build)

This guide for: [Vite](https://pitlane.tools/guides/content.md) · **No Build**

**`pitlane/content`** is the best way to manage Markdown, MDX, JSON, YAML, or any other kind of content for your Remix project, including blog posts, product descriptions, character profiles, recipes, or any structured content. Collections help you organize, validate, and query your documents.

The content package enables you to render content from anywhere, whether it lives in your repo or behind a remote API, fetched once or on every request.

## Install

`pitlane` and `satteri` are runtime dependencies because the content client transforms the Markdown and MDX at runtime when using it within a Remix no-build setup.

```sh
npm add pitlane satteri
```

With yarn, pnpm, bun, vp, vlt, or nub, run the same command with the manager's name in place of `npm`. With deno, put its name in place of `npm` and write each package as `npm:<package>`.

`satteri` is an optional peer dependency of `pitlane`, needed only when a collection holds `.md` or `.mdx` files. `pitlane` also requires `vite` 8.1 or later as a peer dependency, so installing it brings Vite into the project even though nothing here bundles. To leave Vite out, install the standalone `@pitlane/content` package instead and import from `@pitlane/content`. [The `pitlane` Package](https://pitlane.tools/guides/umbrella.md) explains the trade.

## What Is Content?

Pitlane's content package covers a few different dimensions. There is structured data, validated by a schema and consumed as a plain JavaScript object, and there is a renderable body, consumed as a JSX component. Behind both is a source the data is loaded from. Content can be as simple as static JSON or YAML files. Markdown or MDX adds a renderable body while keeping structured data in its frontmatter. Or it could be any arbitrary API call, populating a local store or being refetched on every call. A typical layout:

- `app/`
    - `content.ts` (_the content schema_)
    - `content/`
        - `newsletters/` (_the "newsletters" collection_)
            - `week-1.md` (_a collection entry_)
            - `week-2.md` (_a collection entry_)
            - `week-3.md` (_a collection entry_)
        - `authors.json` (_a single file containing all collection entries for the "authors" collection_)

Collections are defined in the schema file (`app/content.ts`, by convention) which is where you create validations for your content's data and initialize the client for retrieving your content.

```ts title="app/content.ts"
import { createContent } from "pitlane/content";
import * as loaders from "pitlane/content/loaders";
import * as s from "remix/data-schema";
import * as coerce from "remix/data-schema/coerce";

export let content = createContent(c => ({
    newsletters: c.collection({
        loader: loaders.glob({ base: "app/content/newsletters", pattern: "**/*.{md,mdx}" }),
        schema: s.object({
            title: s.string(),
            summary: s.string(),
            publishedOn: coerce.date(),
            author: c.reference("authors"),
        }),
    }),
    authors: c.collection({
        loader: loaders.file("app/content/authors.json"),
        schema: s.object({ name: s.string(), bio: s.string() }),
    }),
}));
```

Two types of content collections are available. A [`ContentLoader`](https://pitlane.tools/package/content/interface/ContentLoader.md) collection loads its data once, and a live collection fetches it on every request. Both use:

- A required `loader` to retrieve your content and data from wherever it is stored and make it available to your project through content-focused APIs.
- A required `schema` that allows you to define the expected shape of each entry for type safety and validation.

Collections stored locally in your repo or on your filesystem can use one of the two provided loaders (from `pitlane/content/loaders`) to fetch data from Markdown, MDX, YAML, or JSON files. Point the content client to the location of your content, define your data schema, and you're good to go with a blog or another content-heavy, mostly static site in no time!

By writing a `ContentLoader` or a [`LiveLoader`](https://pitlane.tools/package/content/interface/LiveLoader.md) yourself, you can fetch remote data from any external source, such as a CMS, database, or headless payment system, once or live on demand.

## Defining Content Collections

All of your content collections are defined using the [`createContent()`](https://pitlane.tools/package/content/function/createContent.md) function from `pitlane/content`. There is no special location your collections must be defined in. Our demos use `app/content.ts` by convention.

`createContent()` returns the content client synchronously. Export it directly and import it where you need it. Construction does no loading. Keep `await` on `getCollection()`, `getEntry()`, and `entry.render()`.

With Vite, `contentLayer()` waits for the declarations to finish, then loads the prebuildable collections before emitting the bundle. Without a bundler, a `ContentLoader` collection loads on its first read. Live collections load on every read in either setup. Neither setup needs an initialization call.

Each individual collection configures:

- a `loader` for a data source (required)
- a `schema` for type safety (required)

```ts title="app/content.ts"
// 1. Import utilities from `pitlane/content`
import { createContent } from "pitlane/content";

// 2. Import loader(s)
import * as loaders from "pitlane/content/loaders";

// 3. Import data schema
import * as s from "remix/data-schema";
import * as coerce from "remix/data-schema/coerce";

// 4. Export a single `content` client to use in your controllers
export let content = createContent(c => ({
    // 5. Define a `loader` and `schema` for each collection
    blog: c.collection({
        loader: loaders.glob({ base: "app/content/blog", pattern: "**/*.{md,mdx}" }),
        schema: s.object({
            title: s.string(),
            description: s.string(),
            pubDate: coerce.date(),
            updatedDate: s.optional(coerce.date()),
        }),
    }),
}));
```

You can then use the [`getCollection()`](https://pitlane.tools/package/content/interface/Collection.md#getcollection) and [`getEntry()`](https://pitlane.tools/package/content/interface/Collection.md#getentry) methods on each content collection to query your content collections' data and render your content.

## Defining the Collection Schema

Schemas enforce consistent frontmatter or entry data within a collection. A schema **guarantees** that this data exists in a predictable form when you need to reference or query it. If any entry violates its collection schema, the error names the collection, the entry, the file, and every field that failed:

```
Failed to parse entry "hello" in collection "blog" (app/content/blog/hello.md):
  - title: Expected string
  - publishedOn: Expected date
```

Schemas also provide the TypeScript types for your content. The parsed shape is what `entry.data` is typed as when you query the collection, so you get property autocompletion and type-checking with nothing generated and nothing that can go stale.

A `schema` is required. Every frontmatter or data property of your collection entries must be defined using a `remix/data-schema` type:

```ts title="app/content.ts"
import { createContent } from "pitlane/content";
import * as loaders from "pitlane/content/loaders";
import * as s from "remix/data-schema";
import * as coerce from "remix/data-schema/coerce";

export let content = createContent(c => ({
    blog: c.collection({
        loader: loaders.glob({ pattern: "**/*.md", base: "app/data/blog" }),
        schema: s.object({
            title: s.string(),
            description: s.string(),
            draft: s.defaulted(s.boolean(), false),
            pubDate: coerce.date(),
            updatedDate: s.optional(coerce.date()),
            tags: s.array(s.string()),
        }),
    }),
    dogs: c.collection({
        loader: loaders.file("app/data/dogs.json"),
        schema: s.object({
            breed: s.string(),
            temperament: s.array(s.string()),
        }),
    }),
}));
```

An entry's `id` is not part of its `data`, so the schema does not declare one. The `file()` loader removes the `id` from each array item before validation, and an object's keys never reach the data at all.

Frontmatter is parsed as YAML, so `pubDate: 2026-01-02` is already a `Date` and `tags: [a, b]` is already an array. `coerce` matters for the fields YAML leaves as strings, such as a quoted date, and for data from an API where everything is a string.

### Defining Datatypes

`remix/data-schema` is one [Standard Schema](https://standardschema.dev) validator. Any other works too, because that is all a schema is asked for, with one catch: [`c.reference()`](https://pitlane.tools/package/content/interface/ContentBuilder.md#reference) has to nest inside a combinator that accepts a foreign schema, which `s.object()` does and Zod's `z.object()` does not.

### Defining Collection References

Collection entries can also "reference" other related entries.

With `c.reference()`, you can define a property in a collection schema as an entry from another collection. It is a schema that accepts a string `id` and produces `{ collection, id }`, and it composes wherever a schema goes, including inside `s.array()` and `s.optional()`.

A common example is a blog post that references reusable author profiles stored as JSON, or related post URLs stored in the same collection:

```ts title="app/content.ts"
import { createContent } from "pitlane/content";
import * as loaders from "pitlane/content/loaders";
import * as s from "remix/data-schema";

export let content = createContent(c => ({
    blog: c.collection({
        loader: loaders.glob({ base: "app/content/blog", pattern: "**/*.{md,mdx}" }),
        schema: s.object({
            title: s.string(),
            // Reference a single author from the `authors` collection by `id`
            author: c.reference("authors"),
            // Reference an array of related posts from the `blog` collection by `id`
            relatedPosts: s.array(c.reference("blog")),
        }),
    }),
    authors: c.collection({
        loader: loaders.glob({ pattern: "**/*.json", base: "app/data/authors" }),
        schema: s.object({
            name: s.string(),
            portfolio: s.string(),
        }),
    }),
}));
```

This example blog post specifies the `id`s of related posts and the `id` of the post author:

```yaml
---
title: "Welcome to my blog"
author: ben-holmes # references `app/data/authors/ben-holmes.json`
relatedPosts:
    - about-me # references `app/content/blog/about-me.md`
    - my-year-in-review # references `app/content/blog/my-year-in-review.md`
---
```

These references are transformed into objects containing a `collection` key and an `id` key, which you hand straight to the referenced collection to [query the related data](#accessing-referenced-data).

A reference is checked for its collection, not for its entry. Naming a collection that does not exist throws synchronously when `createContent()` runs. Errors thrown by the declaration callback also propagate synchronously; loading and rendering errors reject the read or render call:

```
Unknown collection "wrtiers" referenced by createContent; known collections are blog, authors.
```

Pointing at an entry that does not exist is only discovered on lookup, where `getEntry()` resolves to `undefined`.

## Included Loaders

Pitlane provides two built-in loaders ([`glob()`](https://pitlane.tools/package/content/loaders/function/glob.md) and [`file()`](https://pitlane.tools/package/content/loaders/function/file.md)) for reading your local content. Pass the location of your data in your project or on your filesystem, and these loaders read the files and parse them into entries.

### The `glob()` Loader

The `glob()` loader fetches entries from directories of Markdown, MDX, JSON, or YAML files from anywhere on the filesystem. If you store your content entries locally as separate files, such as a directory of blog posts, then the `glob()` loader is all you need to access your content.

This loader requires a `pattern` of entry files to match, using the glob syntax Node's [`fs.glob`](https://nodejs.org/api/fs.html#fspromisesglobpattern-options) supports, and takes an optional `base` directory the pattern resolves against, which defaults to the project root. A unique `id` for each entry is generated from its path relative to `base`, but you can define custom IDs if needed.

```ts title="app/content.ts"
import { createContent } from "pitlane/content";
import { glob } from "pitlane/content/loaders";

export let content = createContent(c => ({
    blog: c.collection({
        loader: glob({ pattern: "**/*.md", base: "app/data/blog" }),
        // ...
    }),
}));
```

#### Defining Custom IDs

When using the `glob()` loader, every entry `id` is the matched file path relative to `base`, with the extension removed. `app/content/blog/2026/hello.mdx` under `base: "app/content/blog"` becomes `2026/hello`. Nothing is slugified or lowercased. The `id` is exactly what you named the file, and it is what you pass to `getEntry()` to query the entry directly from your collection. Because it is a path, a nested directory gives you a nested URL when [creating pages from your content](#generating-routes-from-content).

To derive the `id` from something else, pass a `generateId()` function to the `glob()` loader. `entry` is the matched path with its extension still on, and `data` is the entry's parsed frontmatter. `base` is the directory the pattern resolved against. An `id` read from a `slug` field in the frontmatter is the "permalink" of other tools:

```md
---
title: My Blog Post
slug: my-custom-id/supports/slashes
---

Your blog post content here.
```

```ts title="app/content.ts"
import { createContent } from "pitlane/content";
import * as loaders from "pitlane/content/loaders";

export let content = createContent(c => ({
    blog: c.collection({
        loader: loaders.glob({
            pattern: "**/*.{md,mdx}",
            base: "app/content/blog",
            // Prefer a frontmatter `slug`, falling back to the path-based default
            generateId: ({ data, entry }) => (data.slug as string) ?? entry.replace(/\.mdx?$/, ""),
        }),
        // ...
    }),
}));
```

The schema decides whether `slug` survives into `entry.data`. A key the schema does not declare is dropped, so add `slug: s.optional(s.string())` when a template needs to read it.

### The `file()` Loader

The `file()` loader fetches multiple entries from a single local file defined in your collection. The `file()` loader will automatically detect and parse (based on the file extension) a single array of objects from JSON and YAML files.

```ts title="app/content.ts"
import { createContent } from "pitlane/content";
import { file } from "pitlane/content/loaders";

export let content = createContent(c => ({
    dogs: c.collection({
        loader: file("app/data/dogs.json"),
        // ...
    }),
}));
```

Each entry object in the file must have a unique `id` key property so that the entry can be identified and queried. Unlike the `glob()` loader, the `file()` loader will not automatically generate IDs for each entry.

You can provide your entries as an array of objects with an `id` property, or in object form where the unique `id` is the key:

```jsonc title="app/data/dogs.json"
// Specify an `id` property in each object of an array
[
    { "id": "poodle", "coat": "curly", "shedding": "low" },
    { "id": "afghan", "coat": "short", "shedding": "low" },
]
```

```jsonc title="app/data/dogs.json"
// Each key will be used as the `id`
{
    "poodle": { "coat": "curly", "shedding": "low" },
    "afghan": { "coat": "silky", "shedding": "low" },
}
```

#### Parsing Other Data Formats

Support for parsing single JSON and YAML files into collection entries with the `file()` loader is built-in (unless you have a nested JSON document). To load your collection from unsupported file types, such as `.csv`, you will need to create a parser function. It receives the file's text and must return the parsed entries synchronously.

The following example shows importing a third-party CSV parser then passing a custom `parser` function to the `file()` loader:

```ts title="app/content.ts"
import { createContent } from "pitlane/content";
import * as loaders from "pitlane/content/loaders";
import { parse as parseCsv } from "csv-parse/sync";

export let content = createContent(c => ({
    cats: c.collection({
        loader: loaders.file("app/data/cats.csv", {
            parser: text => parseCsv(text, { columns: true, skipEmptyLines: true }),
        }),
        // ...
    }),
}));
```

#### Nested `.json` Documents

The `parser()` argument can be used to load a single collection from a nested JSON document. This JSON file holds two collections:

```jsonc title="app/data/pets.json"
{ "dogs": [{}], "cats": [{}] }
```

You can separate these collections by passing a custom `parser()` function to the `file()` loader for each collection, which parses the file with `JSON.parse` and picks one key:

```ts title="app/content.ts"
import { createContent } from "pitlane/content";
import * as loaders from "pitlane/content/loaders";

export let content = createContent(c => ({
    dogs: c.collection({
        loader: loaders.file("app/data/pets.json", {
            parser: text => JSON.parse(text).dogs,
        }),
        // ...
    }),
    cats: c.collection({
        loader: loaders.file("app/data/pets.json", {
            parser: text => JSON.parse(text).cats,
        }),
        // ...
    }),
}));
```

## Custom Loaders

Anything that is not a local file is a loader you write, and the shape of the object is the declaration. A `ContentLoader` has a `load()` method that fills a store, and a `LiveLoader` has `loadCollection()` and `loadEntry()` methods that answer one query at a time. Either is passed to `c.collection()` the same way as `glob()` and `file()`:

```ts title="app/content.ts"
import { createContent } from "pitlane/content";
import * as s from "remix/data-schema";
import * as coerce from "remix/data-schema/coerce";

import { releases } from "./loaders/releases.ts";

export let content = createContent(c => ({
    releases: c.collection({
        loader: releases({ repository: "pitlane-tools/pitlane" }),
        schema: s.object({
            tag: s.string(),
            name: s.string(),
            publishedOn: coerce.date(),
            prerelease: s.boolean(),
        }),
    }),
}));
```

A custom loader gets everything a built-in one gets: schema validation, `getCollection()` and `getEntry()`, and `render()` when its entries carry a Markdown or MDX body. The reference documents both interfaces, [`ContentLoader`](https://pitlane.tools/package/content/interface/ContentLoader.md) and [`LiveLoader`](https://pitlane.tools/package/content/interface/LiveLoader.md).

## Querying Collections

Every collection on the `content` object has two methods to query it and return one or more entries.

- `getCollection()` fetches an entire collection and returns an array of entries.
- `getEntry()` fetches a single entry from a collection by its `id`, or resolves to `undefined` when nothing has that `id`.

```ts
import { content } from "../content.ts";

// Get all entries from a collection
let allBlogPosts = await content.blog.getCollection();

// Get a single entry from a collection by `id`
let poodleData = await content.dogs.getEntry("poodle");
```

Every entry carries the same five things:

| Field | What it is |
| --- | --- |
| `id` | the entry's unique identifier within its collection |
| `collection` | the key this entry came from |
| `data` | the frontmatter or entry data, parsed and validated by the schema |
| `filePath` | the file it was read from, when it came from one |
| `render()` | resolves to the entry's `Content` component and its list of `headings` |

`getCollection()` returns entries sorted by `id` ascending. The order does not depend on the filesystem, so the same content always renders the same way. To return entries in any other order, such as blog posts sorted by date, sort them yourself:

```ts
let posts = (await content.blog.getCollection()).sort(
    (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
```

A collection reads its files the first time something queries it, and keeps the result for the life of the process. Relative paths resolve against the directory you start the server from. A failed load is not kept, so the next query tries again.

### Using Content in Components

After querying your collections, you can access each entry's content and metadata directly inside your components. A list of links to your blog posts, showing each entry's frontmatter through its `data` property:

```tsx title="app/actions/blog.tsx"
import { createController } from "remix/router";

import { content } from "../content.ts";
import { routes } from "../routes.ts";

export default createController(routes, {
    actions: {
        async blog({ render }) {
            let posts = await content.blog.getCollection();
            return await render(
                <>
                    <h1>My posts</h1>
                    <ul>
                        {posts.map(post => (
                            <li>
                                <a href={`/blog/${post.id}`}>{post.data.title}</a>
                            </li>
                        ))}
                    </ul>
                </>,
            );
        },
    },
});
```

### Rendering Body Content

Once queried, you can render Markdown and MDX entries to HTML by calling the entry's [`render()`](https://pitlane.tools/package/content/interface/Entry.md#render) method. It resolves to a `Content` component and a list of all rendered headings:

```tsx title="app/actions/blog.tsx"
async post({ params, render }) {
    let post = await content.blog.getEntry(params.slug);
    if (!post) return new Response("Not found", { status: 404 });

    let { Content, headings } = await post.render();
    return await render(
        <article>
            <h1>{post.data.title}</h1>
            <p>Published on: {post.data.pubDate.toDateString()}</p>
            <TableOfContents headings={headings} />
            <Content />
        </article>,
    );
},
```

Nothing parses Markdown until you call `render()`, so listing a collection's titles never pays for the bodies it does not show. Each heading is `{ depth, slug, text }`, and the `slug` matches the `id` on the rendered heading, so a link to `#install` lands on it.

Slugs are the ones GitHub gives the same headings: lowercased, with every character outside letters, marks, digits, and connector punctuation removed, spaces turned into hyphens, and nothing collapsed or trimmed. `## Databases & Data Loading` becomes `databases--data-loading`. Anchors you wrote against a GitHub render, or against Astro, which slugs the same way, keep landing. A heading left with no slug at all, such as `## 🎉`, becomes `heading` instead of an empty `id`, and a repeated slug takes a `-1`, `-2`, … suffix within its document.

A heading's `text`, and the slug made from it, come from what the heading shows on the page. Inline HTML contributes the words inside its tags and never the tags themselves, so `## <span>Visible</span> text` reads `Visible text`. An image contributes nothing, not even its alt text, which is also how GitHub and Astro treat it. The spaces around an image stay, so `## [Docs](/docs) ![Logo](logo.png)` reads `Docs ` and slugs to `docs-`. In MDX, an expression that is a single string literal contributes its value, so `## Hello {"world"}` reads `Hello world` and `## The {"{"} key` reads `The { key`. Any other expression, such as `{name}` or `{1 + 1}`, contributes nothing, because its value is not known until the page renders.

Props on `<Content />` reach an MDX document, which is how you replace the elements it renders with components of your own:

```tsx
<Content components={{ h2: Heading, a: Link }} />
```

Calling `render()` on an entry with no body, such as one from a `.json` file, is an error rather than an empty component, because a blank page is the harder bug to find:

```
Entry "authors/ada" has no renderable content.
```

`render()` does not sanitize a body. A Markdown body's HTML reaches the page as written, and an MDX body runs as code. Render only content you trust, which includes the bodies a `LiveLoader` returns at request time.

Sätteri compiles the body when `render()` is called, and the result is cached with the entry, so a second render of the same post costs nothing. The heading list needs no setup: `pitlane/content` registers the plugin that produces it on `.md` and `.mdx` alike. Extra Sätteri plugins go through the loader:

```ts
loaders.glob({
    pattern: "**/*.md",
    base: "app/content/blog",
    satteri: { hastPlugins: [myPlugin()] },
});
```

> [!WARNING]
>
> **MDX needs a host that allows `new Function`**
>
> Compiling MDX for a request evaluates the compiled body, so the host has to allow it. Node, Bun, and Deno all do.
>
> Markdown has no such requirement and renders anywhere Sätteri runs.

#### Passing Content as Props

A component can also take an entire collection entry as a prop.

Use the [`CollectionEntry`](https://pitlane.tools/package/content/type/CollectionEntry.md) type to correctly type your component's props. It takes the collection itself rather than its name, so it inherits every property of that collection's schema and cannot drift from it:

```tsx title="app/ui/blog-card.tsx"
import type { CollectionEntry } from "pitlane/content";
import type { Handle } from "remix/component";

import type { content } from "../content.ts";

export function BlogCard(handle: Handle<{ post: CollectionEntry<typeof content.blog> }>) {
    // `post` matches your `blog` collection's schema type
    return () => <h2>{handle.props.post.data.title}</h2>;
}
```

Change the schema and the component stops type-checking.

### Filtering Collection Queries

`getCollection()` takes an optional "filter" callback that allows you to filter your query based on an entry's `id` or `data` properties.

You can use this to filter by any content criteria you like, such as a `draft` property that keeps unfinished blog posts off your blog:

```ts
// Filter out content entries with `draft: true`
let publishedBlogEntries = await content.blog.getCollection(({ data }) => {
    return data.draft !== true;
});
```

You can also keep draft pages visible in development but out of production:

```ts
// Filter out content entries with `draft: true` only in production
let blogEntries = await content.blog.getCollection(({ data }) => {
    return process.env.NODE_ENV === "production" ? data.draft !== true : true;
});
```

The filter argument also supports filtering by nested directories within a collection. Since the `id` includes the full nested path, you can filter by the start of each `id` to only return items from a specific nested directory:

```ts
// Filter entries by sub-directory in the collection
let englishDocsEntries = await content.docs.getCollection(({ id }) => {
    return id.startsWith("en/");
});
```

### Accessing Referenced Data

To access [references defined in your schema](#defining-collection-references), first query your collection entry. The references are available on the returned `data` object as `{ collection, id }` values: `entry.data.author`, `entry.data.relatedPosts`.

Then pass each value to the referenced collection's `getEntry()`. A reference type-checks only against the collection it names, so handing `post.data.author` to `content.tags.getEntry()` is a compile error. There is no separate helper for an array of references. Look each one up:

```tsx title="app/actions/blog.tsx"
async post({ params, render }) {
    // First, query a blog post
    let post = await content.blog.getEntry(params.slug);
    if (!post) return new Response("Not found", { status: 404 });

    // Retrieve a single referenced entry: the blog post's author.
    // Equivalent to `content.authors.getEntry("ben-holmes")`
    let author = await content.authors.getEntry(post.data.author);

    // Retrieve an array of referenced entries: all the related posts
    let relatedPosts = await Promise.all(
        post.data.relatedPosts.map(reference => content.blog.getEntry(reference)),
    );

    return await render(
        <article>
            <h1>{post.data.title}</h1>
            <p>Author: {author?.data.name}</p>

            <h2>You might also like:</h2>
            {relatedPosts.map(
                related => related && <a href={`/blog/${related.id}`}>{related.data.title}</a>,
            )}
        </article>,
    );
},
```

A reference is not checked for existence, so each lookup can resolve to `undefined`. That is where a pointer at a deleted or misspelled entry surfaces.

## Generating Routes from Content

Nothing creates a page for a collection entry on its own. A route with a parameter, and a controller action that looks the parameter up, is what turns entries into pages:

```ts title="app/routes.ts"
import { get, route } from "remix/routes";

export let routes = route({
    blog: get("/blog"),
    post: get("/blog/:slug"),
});
```

```tsx title="app/actions/blog.tsx"
import { createController } from "remix/router";

import { content } from "../content.ts";
import { routes } from "../routes.ts";

export default createController(routes, {
    actions: {
        async post({ params, render }) {
            let post = await content.blog.getEntry(params.slug);
            if (!post) return new Response("Not found", { status: 404 });

            let { Content } = await post.render();
            return await render(
                <article>
                    <h1>{post.data.title}</h1>
                    <Content />
                </article>,
            );
        },
    },
});
```

An entry's `id` is a path, so `app/content/blog/hello-world.md` has an `id` of `hello-world` and is served at `/blog/hello-world`. An `id` with a `/` in it, whether from a nested directory or a [custom `id`](#defining-custom-ids), needs a route pattern that accepts one, such as `/blog/*slug`.

That is the whole story for a page rendered on demand: the entry is looked up when the page is requested. To publish the collection as static HTML instead, hand the same paths to prerendering.

The [prerendering guide](https://pitlane.tools/guides/prerendering-no-build.md) covers how to use `@pitlane/crawler` to walk the app from a script and write each response to disk, and a collection's `id`s are the paths to hand it.

## Live Collections

A live collection fetches its data at request time rather than once. That is the choice for data that changes while the server runs, such as inventory, prices, or draft content an editor expects to see without a deploy, and it costs a fetch per request in exchange.

Live collections use the same `createContent()` and the same `c.collection()`, and are queried with the same `getCollection()` and `getEntry()`. What makes a collection live is its loader. A `LiveLoader` has `loadCollection()` and `loadEntry(id)` methods in place of `load()`, and each one returns its entries directly. There are no built-in live loaders, so every live collection uses one you write for your data source:

```ts title="app/content.ts"
import { createContent } from "pitlane/content";
import * as s from "remix/data-schema";
import * as coerce from "remix/data-schema/coerce";

import { cms } from "./loaders/cms.ts";

export let content = createContent(c => ({
    articles: c.collection({
        loader: cms({ endpoint: "https://cms.example.com/api", token: process.env.CMS_TOKEN }),
        schema: s.object({
            title: s.string(),
            updatedOn: coerce.date(),
        }),
    }),
}));
```

The `schema` is validated on every read, because a live source can change its mind between one request and the next.

Errors reach your action the same way a `ContentLoader` collection's do. A loader that throws rejects the query, with the collection named in the error, and `getEntry()` resolves to `undefined` when `loadEntry()` does:

```tsx
async article({ params, render }) {
    let article = await content.articles.getEntry(params.slug);
    if (!article) return new Response("Not found", { status: 404 });

    let { Content } = await article.render();
    return await render(<Content />);
},
```

`loadCollection()` takes no arguments. Filter with the callback `getCollection()` accepts, and when the filtering has to happen at the source, put it in the loader's options and declare a second collection:

```ts
export let content = createContent(c => ({
    articles: c.collection({ loader: cms({ endpoint, token }), schema: article }),
    drafts: c.collection({ loader: cms({ endpoint, token, status: "draft" }), schema: article }),
}));
```

Both kinds of collection can exist in the same `createContent()` call, so each data source gets the kind that fits it. What changes once a collection is live:

- **Nothing is memoized.** A `ContentLoader` collection reads its source on the first query and keeps the result. A live one asks its loader every time.
- **Schema failures surface on every read** rather than once, so a schema that is expensive to run is a cost paid per request.

## Hot Reloading for Your Content

Add one line beside `createContent()`:

```ts title="app/content.ts"
import { hotContent } from "pitlane/content/hot";

export let content = createContent(c => ({ ... }));

await hotContent(content);
```

This call is safe to leave in place for production. [`hotContent()`](https://pitlane.tools/package/content/hot/function/hotContent.md) does nothing unless the process is supervised by [`remix/node-hmr`](https://github.com/remix-run/remix/tree/main/packages/node-hmr), whose `run()` starts `server.ts` as a child process and watches its source. A development script that calls it, started with `NODE_ENV=development`, is the whole setup. Save an edited post and the collection it belongs to is discarded, then the page reloads. A collection whose files did not change keeps what it had.

> [!WARNING]
>
> **A new content file needs a restart**
>
> `remix/node-hmr` reports a file change only for a path it was given, so a post you have just created stays invisible until the server restarts. Edits and deletions to an existing post reload the page as part of HMR.

## Reference

- [`pitlane/content`](https://pitlane.tools/package/content/index.md): [`createContent()`](https://pitlane.tools/package/content/function/createContent.md) and the types
- [`pitlane/content/loaders`](https://pitlane.tools/package/content/loaders.md): [`glob()`](https://pitlane.tools/package/content/loaders/function/glob.md) and [`file()`](https://pitlane.tools/package/content/loaders/function/file.md)

- [`pitlane/content/hot`](https://pitlane.tools/package/content/hot.md): [`hotContent()`](https://pitlane.tools/package/content/hot/function/hotContent.md)
