Content page
Give every published VitNode Content Engine record its own page and URL with delivery, a plugin route and contentDeliveryPage, with missing records and drafts answering 404.
Delivery gives every published record a page at a stable URL, such as /news/welcome-to-the-newsroom. The Content Engine resolves the URL, answers 404 for drafts and unknown slugs, and builds the page's metadata; your plugin owns the page component.
This continues the article from Content list, whose rows already link to /news/{slug}.
Turn on delivery
Edit src/content/article.ts and add delivery after publicApi:
publicApi: {
path: "news",
fields: ["id", "title", "slug", "body", "publishedAt"],
},
delivery: true, true uses every default. The page URL is /{publicApi.path}/:slug, so articles live at /news/:slug. URLs are site-wide, which is why no other plugin's content type may use the same publicApi.path. Delivery adds no columns, so there is nothing to migrate.
Export the plugin's content types
Create src/content.ts. It lists every content type with public URLs:
import { articleContentType } from "@/content/article";
export const contentTypes = [articleContentType];VitNode reads this module by its name. It compares these URLs with your plugin's routes, and dev and build stop with a content-url-without-page error while a content URL has no page, so a broken canonical link never ships. Plugin files lists every file VitNode reads.
Claim the URL
Edit src/routes.ts and add the page below the list. This is an excerpt:
export const routes = definePluginRoutes([
page("/news", {
component: lazy(() => import("./pages/articles-page")),
messages: ["newsroom.articles"],
search: articleList.parseSearch,
}),
page("/news/:slug", {
component: lazy(() => import("./pages/article-page")),
messages: ["newsroom.articles"],
}),
]);Write the page
Create src/pages/article-page.tsx. This is the complete file:
import { DateFormat } from "@vitnode/core/components/date-format";
import { RichTextContent } from "@vitnode/core/components/rich-text";
import { zodRichTextDocument } from "@vitnode/core/content";
import {
definePluginRoute,
type PluginRoutePageProps,
} from "@vitnode/core/routing";
import {
contentDeliveryPage,
contentDeliveryPageHead,
} from "@vitnode/core/tanstack/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { z } from "zod";
import { manifest } from "@/manifest.gen";
const zodArticle = z.object({
body: zodRichTextDocument,
id: z.number(),
publishedAt: z.string().nullable(),
slug: z.string(),
title: z.string(),
});
const loadArticle = async (slug: string) => {
const [resolution, detail] = await Promise.all([
fetcher({
plugin: manifest.id,
method: "get",
module: "content/news",
path: "/delivery/resolve/{slug}",
args: { params: { slug: encodeURIComponent(slug) } },
}),
fetcher({
plugin: manifest.id,
method: "get",
module: "content/news",
path: "/{slug}",
args: { params: { slug: encodeURIComponent(slug) } },
}),
]);
if (resolution.status !== 200) {
throw new Error(
`Resolving the article "${slug}" answered ${resolution.status}.`,
);
}
return contentDeliveryPage({
item: detail.status === 200 ? zodArticle.parse(await detail.json()) : null,
resolution: await resolution.json(),
});
};
type ArticlePageData = Awaited<ReturnType<typeof loadArticle>>;
export const route = definePluginRoute({
load: async ({ params }) => await loadArticle(params.slug),
head: ({ loaderData }) =>
contentDeliveryPageHead(loaderData?.metadata, {
title: loaderData?.item.title,
}),
});
const ArticlePage = ({
loaderData: { item },
}: PluginRoutePageProps<ArticlePageData>) => (
<article className="container mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8">
<header className="flex flex-col gap-2">
<h1 className="text-3xl font-semibold tracking-tight text-balance">
{item.title}
</h1>
{item.publishedAt ? (
<span className="text-muted-foreground text-sm">
<DateFormat date={item.publishedAt} />
</span>
) : null}
</header>
<RichTextContent
className="leading-relaxed text-pretty"
content={item.body}
/>
</article>
);
export default ArticlePage;- The loader asks two public routes at once.
/delivery/resolve/{slug}says whether the URL is a record, an old slug to redirect, or nothing;/{slug}returns the public fields. contentDeliveryPagereturns{ item, metadata }. For an unknown slug or a draft it throws the router's not-found, so the app shows its 404 page with a404status, anditemis nevernullin the component.zodArticlechecks the response and typesbodyas a rich text document.RichTextContentrenders it as React elements, on the server and in the browser.contentDeliveryPageHeadbuilds the head frommetadata. Thetitleoption is the fallback while no SEO field is configured.
load comes before head, so TypeScript infers loaderData.
Check the result
Open http://localhost:3000/news/welcome-to-the-newsroom:

- The page title is
Welcome to the newsroom - VitNode. /news/release-notes-for-october, the draft, shows the not-found page with status404. Publish it in the AdminCP and the same URL works./news/no-such-articleanswers404too.
The loader's first request shows what delivery decided:
curl http://localhost:3000/api/newsroom/content/news/delivery/resolve/welcome-to-the-newsroom{
"type": "content",
"canonicalPath": "/news/welcome-to-the-newsroom",
"canonicalInternalPath": "/news/welcome-to-the-newsroom",
"itemId": 1,
"seo": { "description": null, "title": null },
"openGraph": null,
"robots": null,
"alternates": [],
"hreflang": { "languages": {} },
"isFallback": false,
"hidden": false,
"locale": null,
"requestedLocale": null
}seo is empty because no SEO fields are configured yet.
Optional next steps
- Titles, descriptions and the sitemap. Set
delivery.seoanddelivery.sitemap, as SEO shows. - Redirects after a slug change. Add
editorial: trueanddelivery: { redirects: true }. The old slug is kept incore_content_slug_history, andcontentDeliveryPageturns a visit to it into a308redirect to the new URL. While a record is hidden, its page and old slugs answer404. - Edit and Report buttons. Permissions and Reports add them next to the title.
- A custom URL. Set
delivery.path, such as"/articles/:slug". It needs exactly one:slugand cannot start with/adminor/api.
Render other fields
The detail route returns each field in publicApi.fields in a shape made for rendering. The blog's plugins/blog/src/pages/post-page.tsx uses all of them:
| Field kind | What /{slug} returns | Render it with |
|---|---|---|
| Text, textarea, slug | string (or null when nullable) | Plain JSX. React escapes it |
| Rich text | A ProseMirror document. Validate it with zodRichTextDocument | RichTextContent from @vitnode/core/components/rich-text |
Dates such as publishedAt | An ISO string, or null | DateFormat from @vitnode/core/components/date-format inside a <time dateTime> |
| Files | { id, url, name, size, mimeType, width?, height?, alts? }, never the stored file id | An <img> with url, width and height; pick the alt text with resolveImageAlt from @vitnode/core/lib/files/resolve-alt |
People from publicApi.people | { id, name, fullName, nameCode, avatarColor, avatarUrl, role }, or null for a deleted account | UserFormat from @vitnode/core/components/user-format, Avatar from @vitnode/core/components/avatar |
| To-one relations | { id } | Load the related record separately when you need more than its id |
<p className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm leading-relaxed">
{t("posted_by")}
{item.author ? (
<UserFormat format link user={item.author} />
) : (
<span>{t("deleted_user")}</span>
)}
</p>
<RichTextContent
className="text-foreground leading-relaxed text-pretty"
content={item.body}
/>RichTextContent renders the document as React elements on the server and in the browser, without dangerouslySetInnerHTML. Validate an author with zodContentPublicPerson from @vitnode/core/content when your page parses the response with Zod.
Pages without delivery
Delivery is optional. A content type without it still has GET /{slug}, and your page reads it directly. The newsroom plugin's letters do this, in plugins/newsroom/src/pages/letter-page.tsx. contentPageItem from @vitnode/core/tanstack/content throws the router's not-found for a null record, like contentDeliveryPage does:
const detail = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/letters",
path: "/{slug}",
args: { params: { slug: encodeURIComponent(slug) } },
});
const item = contentPageItem(
detail.status === 200 ? zodLetter.parse(await detail.json()) : null,
);Without delivery there are no slug redirects, no canonical metadata and no sitemap entries, so write head yourself. The SEO guide covers the head helpers.
Localized URLs
Every URL delivery builds follows the app's localized URL rules: language prefixes, per-language domains and translated route segments. The blog plugin's posts are a working example. They are localized and live at /blog/article/:slug, and the web app translates that route for Polish:
i18n: {
routePaths: {
pl: {
'/blog/article/:slug': '/wpisy/:slug',
},
},
},The blog's sitemap then lists /blog/article/english-title for English and /pl/wpisy/polish-title for Polish, and only published translations become hreflang alternates. Every delivery option is in the content type reference.
Next, show the newest articles under each one with an Embedded list.