Content
@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/content is a runtime dependency. Markdown and MDX add two more dependencies, and both are build-only: the plugin compiles the bodies, and contentLayer() calls satteri directly to retrieve a Markdown file's heading list.
npm add @pitlane/content
npm add -D satteri vite-plugin-satteriyarn add @pitlane/content
yarn add -D satteri vite-plugin-satteripnpm add @pitlane/content
pnpm add -D satteri vite-plugin-satteribun add @pitlane/content
bun add -D satteri vite-plugin-satterideno add npm:@pitlane/content
deno add -D npm:satteri npm:vite-plugin-satterivp add @pitlane/content
vp add -D satteri vite-plugin-satterivlt add @pitlane/content
vlt add -D satteri vite-plugin-satterinub add @pitlane/content
nub add -D satteri vite-plugin-satteriWhen writing a custom LiveLoader that returns a Markdown body which renders at request time even in a bundled application, make sure to declare satteri as a runtime dependency instead of a dev dependency.
@pitlane/dev is assumed here and installs itself the same way, as a dev dependency.
Configuring Vite
Register two plugins in your Vite config, before remix():
// vite.config.ts
import { headings, rawStyles } from "@pitlane/content/satteri";
import { contentLayer } from "@pitlane/content/vite";
import { remix } from "@pitlane/dev";
import { defineConfig } from "vite";
import satteri from "vite-plugin-satteri";
export default defineConfig({
plugins: [
satteri({
mdx: { jsxImportSource: "remix/ui" },
mdastPlugins: [headings()],
hastPlugins: [rawStyles()],
}),
contentLayer(),
remix(),
],
});satteri() compiles Markdown and MDX bodies during the build. jsxImportSource: "remix/ui" is required. It is what makes a compiled MDX file a Remix component rather than a React one. headings() collects the heading list that render() returns, and rawStyles() keeps the CSS inside a <style> element intact, which any content with a highlighted code block produces.
contentLayer() executes app/content.ts in Node during the build and inlines every collection's entries into the bundle. Entry data becomes plain values, and Markdown bodies become modules the bundler compiles, so a deployed application never reads the filesystem. That is what lets the same collections run on Cloudflare Workers. Keep that module to collection declarations, with no cloudflare:workers imports and nothing that needs a live server. A module at another path is named with entry:
contentLayer({ entry: "app/collections.ts" });A collection of only .json or .yaml files needs none of the Sätteri setup. contentLayer() alone covers it.
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.
// 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 collection loads its data once, and a live collection fetches it on every request. Both use:
- A required
loaderto retrieve your content and data from wherever it is stored and make it available to your project through content-focused APIs. - A required
schemathat 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 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() function from @pitlane/content. There is no special location your collections must be defined in, though the contentLayer() plugin from @pitlane/content uses app/content.ts as the default. If you change this location, you'll need to make sure to update the entry option of the contentLayer() plugin in your vite.config.ts file.
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
loaderfor a data source (required) - a
schemafor type safety (required)
// 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() and 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 dateSchemas 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:
// 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 validator. Any other works too, because that is all a schema is asked for, with one catch: c.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:
// 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 ids of related posts and the id of the post author:
---
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.
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() and file()) 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 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.
// 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.
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:
---
title: My Blog Post
slug: my-custom-id/supports/slashes
---
Your blog post content here.// 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.
// 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:
// 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" },
]// 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:
// 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:
// 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:
// 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():
// 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. Both interfaces are documented in the @pitlane/content reference.
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 itsid, or resolves toundefinedwhen nothing has thatid.
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:
let posts = (await content.blog.getCollection()).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);With contentLayer() in the Vite config, every ContentLoader collection is resolved during the build and its entries are inlined into the bundle. A query at request time reads nothing from disk. A live collection is the exception and runs its loader per read.
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:
// 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() method. It resolves to a Content component and a list of all rendered headings:
// 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.
Props on <Content /> reach an MDX document, which is how you replace the elements it renders with components of your own:
<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.A .md or .mdx body is compiled during the build by the satteri() plugin configured above, and render() hands back the compiled result.
Passing Content as Props
A component can also take an entire collection entry as a prop.
Use the CollectionEntry 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:
// app/ui/blog-card.tsx
import type { CollectionEntry } from "@pitlane/content";
import type { Handle } from "remix/ui";
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:
// 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:
// Filter out content entries with `draft: true` only when building for production
let blogEntries = await content.blog.getCollection(({ data }) => {
return import.meta.env.PROD ? 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:
// 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, 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:
// 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:
// app/routes.ts
import { get, route } from "remix/routes";
export let routes = route({
blog: get("/blog"),
post: get("/blog/:slug"),
});// 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, 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.
remix({ prerender }) renders those pages during vite build. Pass a function, and the paths come from the collection itself:
// vite.config.ts
import { content } from "./app/content.ts";
remix({
async prerender({ getStaticPaths }) {
let posts = await content.blog.getCollection();
return [...getStaticPaths(), ...posts.map(post => `/blog/${post.id}`)];
},
});Prerendering covers the other shapes that option takes.
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:
// 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:
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:
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:
contentLayer()leaves it alone. There is no single execution for the build to run, so the loader runs per read wherever the application is deployed, and its source has to be reachable from there.- Bodies render at request time. Markdown needs
satteriinstalled as a runtime dependency rather than a dev one, as noted under Install. MDX also needsnew Function, which Cloudflare Workers forbids, so a live collection cannot serve.mdxthere.
Hot Reloading for Your Content
The contentLayer() plugin watches every path the loaders report. Editing, adding, or deleting a content file rebuilds the affected collection and reloads the page, so nothing here needs a restart. A LiveLoader re-reads on every request anyway and has nothing to watch.
Reference
@pitlane/content:createContent()and the types@pitlane/content/loaders:glob()andfile()@pitlane/content/satteri: theheadings()andrawStyles()plugins@pitlane/content/vite: thecontentLayer()plugin