Documentation

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:

plugins/newsroom/src/content/article.ts
  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:

plugins/newsroom/src/content.ts
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:

plugins/newsroom/src/routes.ts
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:

plugins/newsroom/src/pages/article-page.tsx
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.
  • contentDeliveryPage returns { item, metadata }. For an unknown slug or a draft it throws the router's not-found, so the app shows its 404 page with a 404 status, and item is never null in the component.
  • zodArticle checks the response and types body as a rich text document. RichTextContent renders it as React elements, on the server and in the browser.
  • contentDeliveryPageHead builds the head from metadata. The title option 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 Welcome to the newsroom article page with its title, publish date and body text
  • The page title is Welcome to the newsroom - VitNode.
  • /news/release-notes-for-october, the draft, shows the not-found page with status 404. Publish it in the AdminCP and the same URL works.
  • /news/no-such-article answers 404 too.

The loader's first request shows what delivery decided:

curl http://localhost:3000/api/newsroom/content/news/delivery/resolve/welcome-to-the-newsroom
200
{
  "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.seo and delivery.sitemap, as SEO shows.
  • Redirects after a slug change. Add editorial: true and delivery: { redirects: true }. The old slug is kept in core_content_slug_history, and contentDeliveryPage turns a visit to it into a 308 redirect to the new URL. While a record is hidden, its page and old slugs answer 404.
  • 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 :slug and cannot start with /admin or /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 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/newsroom/src/pages/letter-page.tsx (excerpt)
<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:

plugins/newsroom/src/pages/letter-page.tsx (excerpt)
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:

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.

Next, show the newest articles under each one with an Embedded list.