Documentation

Public pages

Give every published VitNode Content Engine record its own URL with delivery, a plugin page, slug redirects and localized URLs.

Delivery turns a published record into a web page at a stable URL, such as /articles/hello-content-engine. The Content Engine resolves the URL, answers 404 for drafts, redirects retired slugs and builds the page's SEO metadata from fields you choose. Your plugin owns the page component, so the content type, its API and its URL stay in one plugin.

The content type needs publication and a public API. This guide continues with example.article.

Turn on delivery

Add a delivery block to the definition:

plugins/example/src/content/article.ts
export const articleContentType = defineContentType({
  id: "example.article",
  delivery: {
    redirects: true,
    seo: {
      titleField: "title",
      descriptionField: "excerpt",
      noIndexField: "noIndex",
      openGraph: { titleField: "title", descriptionField: "excerpt" },
    },
    sitemap: { changeFrequency: "weekly", priority: 0.7 },
  },
});
  • Every SEO field must also be in publicApi.fields, because the page renders it publicly. That is why the example lists noIndex there.
  • noIndexField names a boolean. When it is true, the page gets noindex, nofollow and the record leaves the sitemap.
  • redirects needs editorial: true, which the example article already has. sitemap needs publication.
  • The page URL defaults to /{publicApi.path}/:slug, so articles live at /articles/:slug. Set delivery.path to use another URL. It needs exactly one :slug and cannot start with /admin or /api.

Export the plugin's content types

Export every content type with public URLs from src/content.ts:

plugins/example/src/content.ts
import { advancedArticleContentType } from "@/content/advanced-article";
import { articleContentType } from "@/content/article";
import { categoryContentType } from "@/content/category";
import { localizedArticleContentType } from "@/content/localized-article";
import { pageContentType } from "@/content/page";

export const contentTypes = [
  articleContentType,
  advancedArticleContentType,
  localizedArticleContentType,
  categoryContentType,
  pageContentType,
];

The build compares these URLs with your plugin's routes. A content type whose URL no page serves stops dev and build with a content-url-without-page error, so a broken canonical link never ships. Plugin files explains how the build finds this module.

Claim the URL

Add a page for the same path to the plugin's routes:

plugins/example/src/routes.ts
export const routes = definePluginRoutes([
  page("/articles/:slug", {
    component: lazy(() => import("./pages/article-page")),
    messages: ["@vitnode/example.articles"],
  }),
]);

Write the page

The loader calls two public routes in parallel. /delivery/resolve/{slug} says whether the URL is a record, an old slug to redirect, or nothing. /{slug} returns the record's public fields.

plugins/example/src/pages/article-page.tsx
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";

import { ContentArticle } from "./content-article";

const zodArticle = z.object({
  excerpt: z.string().nullable(),
  publishedAt: z.string().nullable(),
  title: z.string(),
});

const loadArticle = async (slug: string) => {
  const [resolution, detail] = await Promise.all([
    fetcher({
      plugin: manifest.id,
      method: "get",
      module: "content/articles",
      path: "/delivery/resolve/{slug}",
      args: { params: { slug: encodeURIComponent(slug) } },
    }),
    fetcher({
      plugin: manifest.id,
      method: "get",
      module: "content/articles",
      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>) => (
  <ContentArticle
    lead={item.excerpt}
    publishedAt={item.publishedAt}
    title={item.title}
  />
);

export default ArticlePage;
  • contentDeliveryPage returns { item, metadata }. It throws a not-found for an unknown slug or a draft, and a 308 redirect for a retired slug.
  • contentDeliveryPageHead sets the title, the meta description (HTML stripped, cut at 160 characters), robots for a no-index record and hreflang alternates for localized content.
  • load comes before head, so TypeScript can infer loaderData. ContentArticle is the plugin's own layout component.

Render the record's 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 kindWhat /{slug} returnsRender it with
Text, textarea, slugstring (or null when nullable)Plain JSX. React escapes it
Rich textA ProseMirror document. Validate it with zodRichTextDocumentRichTextContent from @vitnode/core/components/rich-text
Dates such as publishedAtAn ISO string, or nullDateFormat from @vitnode/core/components/date-format inside a <time dateTime>
Files{ id, url, name, size, mimeType, width?, height?, alts? }, never the stored file idAn <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 accountUserFormat 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
plugins/example/src/pages/discussion-page.tsx
<p className="text-muted-foreground flex items-center gap-2 text-sm">
  {t("posted_by")}
  {item.author ? (
    <UserFormat format link user={item.author} />
  ) : (
    <span>{t("deleted_user")}</span>
  )}
</p>

<RichTextContent className="leading-relaxed" 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 example plugin's discussions do this. contentPageItem from @vitnode/core/tanstack/content throws the router's not-found for a null record, like contentDeliveryPage does:

plugins/example/src/pages/discussion-page.tsx
const detail = await fetcher({
  plugin: manifest.id,
  method: "get",
  module: "content/discussions",
  path: "/{slug}",
  args: { params: { slug: encodeURIComponent(slug) } },
});

const item = contentPageItem(
  detail.status === 200 ? zodDiscussion.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.

Missing, hidden and unavailable records

The public routes answer the same 404 for a slug that never existed, a draft, a scheduled record and a record whose translation is unpublished, so a page cannot reveal that a draft exists. Both contentDeliveryPage and contentPageItem turn that into the app's not-found page. Give the route a notFound component when you want a page-specific message.

A hidden record is the exception. Its author and moderators who may view hidden records still get it, with Cache-Control: private, no-store and noindex, and delivery sets metadata.hidden: true. The blog uses that flag to show a "hidden" notice and to drop the Report button. Everyone else gets 404.

Add Edit and Report buttons

Put Edit and Report next to the title. useContentPermissions tells you which ones this visitor gets, as canEdit and canReport. See Public edit form and Report content.

Keep old URLs working

Renaming a slug breaks links people already shared. With delivery.redirects, the old slug is stored in core_content_slug_history when an editor changes it, and the resolve route answers { "type": "redirect", "status": 308, "location": "/articles/new-slug" }. contentDeliveryPage turns that into a 308 redirect. While a record is hidden, its page and its old slugs answer 404 instead.

Sitemap entries

delivery.sitemap lists every published URL for crawlers. SEO covers the route, paging and turning the entries into XML.

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:

apps/web/src/vitnode.config.ts
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.

Check the result

Open http://localhost:3000/articles/hello-content-engine. Delivery adds no columns, so there is nothing to migrate.

The page head carries the article's title and excerpt:

curl -s http://localhost:3000/articles/hello-content-engine | grep -oE '<title>[^<]*</title>|<meta content="[^"]*" name="description"/>'
<title>Hello Content Engine - VitNode</title>
<meta
  content="A first article from the example plugin, served by the public API and rendered on its own page."
  name="description"
/>

The resolve route shows what the loader received. openGraph, canonicalPath and hreflang are there too if you want to render more tags yourself:

curl http://localhost:3000/api/@vitnode/example/content/articles/delivery/resolve/hello-content-engine
{
  "type": "content",
  "canonicalPath": "/articles/hello-content-engine",
  "canonicalInternalPath": "/articles/hello-content-engine",
  "seo": {
    "title": "Hello Content Engine",
    "description": "A first article from the example plugin, served by the public API and rendered on its own page."
  },
  "openGraph": {
    "title": "Hello Content Engine",
    "description": "A first article from the example plugin, served by the public API and rendered on its own page."
  },
  "robots": { "follow": true, "index": true },
  "alternates": [],
  "hreflang": { "languages": {} },
  "isFallback": false,
  "itemId": null,
  "locale": null,
  "requestedLocale": null
}

itemId is null because id is not in the article's publicApi.fields. Unpublish the article and the same URL shows the app's not-found page.

Public article page with Published Oct 10, the title Hello Content Engine and its excerpt as the lead