Container pages
Read Content Engine containers with the typed fetcher, render a nested desk tree, list the records of a container and its subcontainers with includeDescendants, and let members create content inside a container through publicWrite.
This guide builds the member-facing side of the newsroom plugin's desks: a /desks page with the nested desk tree, a /desks/:slug page that lists the letters to the editor sent to a desk and every desk below it, and a desk picker on the "Write a letter" form.
The finished pages are in plugins/newsroom/src/pages/, in the desk-* and letter-* files. The code below is the same, with some styling trimmed.
Before you begin
You need a container collection with publicApi, and a content type whose field.container() is public, filterable and writable on create, as set up in Containers. The newsroom's collection has publicApi.path: "desks", so the fetcher's module is content/desks.
The collection's public routes are read-only and need no session:
| Route | Returns |
|---|---|
GET / | { items }: every container in tree order. ?parent=root or ?parent={id} returns one level |
GET /{idOrSlug} | { container, ancestors, children }: ancestors root first, direct children in order |
Each item is { id, parentId, name, slug, position, depth, metadata }, where metadata holds only publicApi.fields. An unknown container or parent answers 404. A numeric segment is read as an id, anything else as a slug.
Add the routes
Register the two desk pages in src/routes.ts, and give the create page a search parser so it can read ?desk=. A pendingComponent is the fallback while a page suspends, not a navigation screen. Navigations keep the current page and show the top progress bar. It can render before the page's chunk arrives, so import it directly rather than through lazy():
import {
FeedPendingSkeleton,
RoutePendingSkeleton,
} from "@vitnode/core/tanstack/pending";
import { deskSearch } from "./pages/desk-search";
import { letterNewSearch } from "./pages/letter-search";
export const routes = definePluginRoutes([
page("/desks", {
component: lazy(() => import("./pages/desks-page")),
messages: ["newsroom.desks"],
pendingComponent: RoutePendingSkeleton,
}),
page("/desks/:slug", {
component: lazy(() => import("./pages/desk-page")),
messages: ["newsroom.desks"],
pendingComponent: FeedPendingSkeleton,
search: deskSearch,
}),
page("/letters/new", {
component: lazy(() => import("./pages/letter-new-page")),
messages: ["newsroom.letters"],
requires: "authenticated",
search: letterNewSearch,
}),
]);deskSearch keeps a valid cursor for paging through letters, and letterNewSearch keeps a positive desk id. Both are small functions in desk-search.ts and letter-search.ts. The strings live under newsroom.desks in src/locales/en.json.
Load the tree
Create a helper that reads every desk. The list comes back in tree order, flat, so one request loads the whole tree:
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { manifest } from "@/manifest.gen";
export const fetchDesks = async () => {
const res = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/desks",
path: "/",
args: { query: {} },
});
if (res.status !== 200) throw new Error("Loading the desks failed.");
const { items } = await res.json();
return items;
};
export type Desk = Awaited<ReturnType<typeof fetchDesks>>[number];
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
export const deskColor = (desk: Pick<Desk, "metadata">): null | string =>
typeof desk.metadata.color === "string" && HEX_COLOR.test(desk.metadata.color)
? desk.metadata.color
: null;Desk is inferred from the API, so desk.metadata.color is typed as string | null from the definition's fields, and desk.metadata.locked is a compile error because locked is not public. deskColor accepts only a hex value before it goes into a style attribute, because an admin typed it.
The pages read the list through one TanStack Query entry, so the desks page, the letter form and a desk page share it:
export const desksQueryOptions = () => ({
meta: LOADER_DATA_QUERY_META,
queryFn: fetchDesks,
queryKey: deskQueryKeys.desks(),
staleTime: RECORD_STALE_TIME,
});Render the tree
contentContainerTree from @vitnode/core/content nests a tree-ordered list: each node gets a children array. Render it recursively as nested lists inside a labelled nav, so screen readers announce the structure:
import type { ContentContainerTreeNode } from "@vitnode/core/content";
import { HrefLink } from "@vitnode/core/tanstack/link";
import type { Desk } from "./desks-data";
import { deskHref } from "./desk-search";
import { deskColor } from "./desks-data";
export const DeskColorDot = ({ desk }: { desk: Desk }) => {
const color = deskColor(desk);
return (
<span
aria-hidden="true"
className={`size-3 shrink-0 rounded-full border${color === null ? " bg-muted" : ""}`}
style={color === null ? undefined : { backgroundColor: color }}
/>
);
};
const DeskTreeItems = ({
nodes,
}: {
nodes: ContentContainerTreeNode<Desk>[];
}) => (
<ul className="flex flex-col gap-2">
{nodes.map(node => (
<li className="flex flex-col gap-2" key={node.id}>
<HrefLink
className="flex flex-col gap-1 rounded-lg border p-4"
href={deskHref(node.slug)}
>
<span className="text-foreground flex items-center gap-2 font-medium text-balance">
<DeskColorDot desk={node} />
{node.name}
</span>
{node.metadata.description ? (
<span className="text-muted-foreground text-sm leading-relaxed text-pretty">
{node.metadata.description}
</span>
) : null}
</HrefLink>
{node.children.length > 0 ? (
<div className="border-l pl-4 md:pl-6">
<DeskTreeItems nodes={node.children} />
</div>
) : null}
</li>
))}
</ul>
);
export const DeskTree = ({
label,
nodes,
}: {
label: string;
nodes: ContentContainerTreeNode<Desk>[];
}) => (
<nav aria-label={label}>
<DeskTreeItems nodes={nodes} />
</nav>
);contentContainerTree keeps the server's order, so siblings appear in the order staff arranged them. It works on any rows with id and parentId, including the AdminCP records.
Add the desks page
The route's load reads the cached desks and nests them, and the page shows the tree, or an empty state when no desk exists yet. The same load also warms the latest letters list that the page shows under the tree:
import { contentContainerTree } from "@vitnode/core/content";
import {
defineRoute,
type RouteLoadContext,
} from "@vitnode/core/tanstack/plugin-routes";
import { useTranslations } from "use-intl";
import { desksQueryOptions } from "./desk-queries";
import { DeskTree } from "./desk-tree";
import { LatestLetters } from "./latest-letters";
import { lettersQueryOptions } from "./letter-list";
const loadDesks = async (
queryClient: RouteLoadContext["queryClient"],
locale: string,
) => {
const [desks] = await Promise.all([
queryClient.query(desksQueryOptions()),
// No `LOADER_DATA_QUERY_META`: the entry is dehydrated, so
// `LatestLetters` finds it in the browser and renders on the server.
queryClient
.query(lettersQueryOptions({ locale, search: {} }))
.catch(() => null),
]);
return { tree: contentContainerTree(desks) };
};
type DesksPageData = Awaited<ReturnType<typeof loadDesks>>;
export const route = defineRoute<DesksPageData>({
load: async ({ context }) =>
await loadDesks(context.queryClient, context.locale),
head: ({ t }) => ({
description: t("newsroom.desks.desc"),
title: t("newsroom.desks.title"),
}),
breadcrumb: DesksBreadcrumb,
});
const DesksPage = ({ loaderData }: PluginRoutePageProps<DesksPageData>) => {
const t = useTranslations("newsroom.desks");
return (
<div className="container mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8 md:gap-8 md:py-12">
{/* The header: title, description and a "Write a letter" button */}
{loaderData.tree.length === 0 ? (
<Empty className="border">{/* "No desks yet" */}</Empty>
) : (
<DeskTree label={t("tree")} nodes={loaderData.tree} />
)}
<LatestLetters />
</div>
);
};
export default DesksPage;The full page also has the breadcrumb component, a Write a letter button and an Empty state with an icon.
Add the desk page
/desks/:slug makes two requests. The detail route returns the desk, its ancestors for the breadcrumb and its child desks. The letters list is filtered by the desk, and includeDescendants: "true" widens the filter to every desk below it, so Politics also lists what was sent to Elections:
import { contentPageItem } from "@vitnode/core/tanstack/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { z } from "zod";
import { manifest } from "@/manifest.gen";
import type { DeskSearch } from "./desk-search";
import { cachedDeskId, queryDesk } from "./desk-queries";
const PAGE_SIZE = 20;
const zodLetterRow = z.object({
id: z.number(),
publishedAt: z.string().nullable(),
slug: z.string().nullable(),
title: z.string(),
});
const fetchLetters = async (deskId: number, cursor?: string) => {
const filters = {
desk: String(deskId),
includeDescendants: "true",
};
const list = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/letters",
path: "/",
args: {
query: {
...filters,
first: String(PAGE_SIZE),
...(cursor ? { cursor } : {}),
},
},
});
if (list.status !== 200) throw new Error("Loading the letters failed.");
const { edges, pageInfo } = await list.json();
return {
letters: z.array(zodLetterRow).parse(edges),
nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null,
};
};
const loadDesk = async (
queryClient: RouteLoadContext["queryClient"],
idOrSlug: string,
{ cursor }: DeskSearch,
) => {
// The list needs the desk's id. When the desks index already cached it,
// both requests go out together instead of one after the other.
const knownId = cachedDeskId(queryClient, idOrSlug);
const [desk, early] = await Promise.all([
queryDesk(queryClient, idOrSlug),
// A stale id (the desk was moved or deleted since) is not this page's
// error: the desk read below decides, and the list is fetched again.
knownId === null ? null : fetchLetters(knownId, cursor).catch(() => null),
]);
const { ancestors, children, container } = contentPageItem(desk);
const page =
early && knownId === container.id
? early
: await fetchLetters(container.id, cursor);
return {
ancestors,
children,
container,
...page,
isFirstPage: cursor === undefined,
};
};queryDeskindesk-queries.tscallsGET content/desks/{idOrSlug}and returnsnullfor a404.contentPageItem(null)turns that into the route's not-found page.- The filter parameter is named after the field,
desk, and works only becausedeskis inpublicApi.filterableFields. WithoutincludeDescendants, it matches that one desk. - Filter parameters are not part of the inferred query type yet, so they are spread in from a small object instead of written as literal keys.
- An unknown desk id in the filter answers
404, not an empty list, so a stale link never looks like an empty desk. nextCursorgoes into?cursor=for an Older letters link. Pass it back unchanged.
Declare the trail as a breadcrumb group, one crumb per ancestor, and give the route a notFound component for the 404:
export const route = defineRoute<DeskPageData, DeskSearch>({
load: async ({ context, params, search }) =>
await loadDesk(context.queryClient, params.slug, search),
head: ({ loaderData }) => ({
description: loaderData?.container.metadata.description ?? undefined,
title: loaderData?.container.name,
}),
breadcrumb: {
group: ({ loaderData }) => (
<>
<BreadcrumbItem>
<BreadcrumbLink render={<HrefLink href={DESKS_HREF} />}>
<DesksCrumb />
</BreadcrumbLink>
</BreadcrumbItem>
{loaderData.ancestors.map(ancestor => (
<Fragment key={ancestor.id}>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink
render={<HrefLink href={deskHref(ancestor.slug)} />}
>
{ancestor.name}
</BreadcrumbLink>
</BreadcrumbItem>
</Fragment>
))}
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{loaderData.container.name}</BreadcrumbPage>
</BreadcrumbItem>
</>
),
},
notFound: DeskNotFound,
});The page renders the children with the same DeskTree, the letters as a list of links, and a Write a letter link to /letters/new?desk={container.id}. See the full component in desk-page.tsx.
Let members pick a desk
Load the same cached desks for the create page and turn them into select options, indented by depth so the select reads like the tree:
const loadDeskOptions = async (queryClient: RouteLoadContext["queryClient"]) =>
(await queryClient.query(desksQueryOptions())).map(desk => ({
label: `${"ā ".repeat(desk.depth - 1)}${desk.name}`,
value: String(desk.id),
}));
export const route = defineRoute<LetterNewData, LetterNewSearch>({
load: async ({ context }) => await loadDeskOptions(context.queryClient),
});Add the desk to the form schema. A select works with strings, so the ids become a z.enum, preselected from ?desk= when it names a real desk:
const createLetterCreateFormSchema = (
t: (
key: "body.required" | "desk.required" | "title.max" | "title.min",
) => string,
ids: [string, ...string[]],
selected: string | undefined,
) => {
const desk = z.enum(ids, { error: t("desk.required") });
return createLetterFormSchema(t, {
body: emptyRichTextDocument(),
title: "",
}).extend({
desk:
selected !== undefined && ids.includes(selected)
? desk.default(selected)
: desk,
});
};Render it with AutoFormSelect before the title and body fields:
{
id: "desk",
component: props => (
<AutoFormSelect
{...props}
description={t("desk.desc")}
label={t("desk.label")}
labels={desks.options}
placeholder={t("desk.placeholder")}
/>
),
},On submit, send the desk as a number inside values, like every other writable field. LETTER_FIELDS in letter-form.tsx is ["title", "body", "desk"]:
const write = useContentPublicWrite({
fields: LETTER_FIELDS,
uniqueField: "title",
});
const onSubmit: AutoFormOnSubmit<LetterCreateFormSchema> = async (
{ desk, ...values },
form,
) => {
const record = await write.submit(
form,
async () =>
await fetcher({
plugin: manifest.id,
method: "post",
module: "content/letters",
path: "/",
args: { body: { values: { ...values, desk: Number(desk) } } },
}),
);
if (!record) return;
setIsLeaving(true);
await invalidateLetterLists(queryClient);
await navigate(
record.slug === null
? letterEditHref(record.id)
: letterHref(record.slug, { created: true }),
);
};The server never trusts the id. It reads the desk back inside the write's transaction and checks that it belongs to newsroom.desks. A desk that was deleted while the form was open, or one from another collection, answers 400 with an issue on the desk field:
{
"code": "CONTENT_INVALID_INPUT",
"contentTypeId": "newsroom.letter",
"issues": [
{
"code": "unavailable",
"field": "desk",
"message": "The selected container does not exist."
}
]
}Because "desk" is in fields, useContentPublicWrite shows that issue under the desk select.
The edit form stays as it was. A container field cannot be in publicWrite.update.fields, so moving a letter to another desk is a staff edit in the AdminCP.
Check the result
- Create Politics with Elections inside it in the AdminCP, then open
/desks. Both desks show, Elections nested under Politics. - Open Elections and click Write a letter. The form opens with ā Elections preselected. Send it.
- Open
/desks/politics. The letter is listed there too, because ofincludeDescendants, and the breadcrumb ends with Desks and Politics. - Open
/desks/nope. The not-found page shows.
Errors to handle
| Request | Answer | What the page does |
|---|---|---|
GET content/desks/{idOrSlug} for an unknown desk | 404 | contentPageItem(null) renders the route's notFound |
GET content/desks?parent={id} for an unknown parent | 404 | Treat it like a missing desk |
GET content/letters?desk={id} for an unknown desk | 404 | Treat it like a missing desk |
POST content/letters with a missing, deleted or foreign desk | 400, issue on desk | Show the message under the select |
POST content/letters with no desk at all | 400, issue on desk | The form's own required check catches it first |
The public write status codes cover sign-in, permission and rate-limit answers, which work the same with or without containers.