PolicykitDocs

Rendering policies

Put a policy on the page with PolicyDocument: the live route, the archive route, the guards that 404, and the MDX component map.

5 min readUpdated 11 August 2026
Rendering policies

This page covers the two routes every policy needs: the live page at /[locale]/legal/terms and the archive page at /[locale]/legal/terms/[revision]. Both render through PolicyDocument, an async server component that emits one revision's MDX body and nothing else.

Prerequisites: a configured Policy instance (see getting started) and next-mdx-remote (^5 || ^6) installed.

What PolicyDocument takes

import { PolicyDocument } from "@daanvandenbergh/policykit/react";
PropTypeRequiredMeaning
policyPolicyyesThe configured instance to render from.
localestringyesThe locale to render. Must exist for the resolved revision.
revisionstringnoThe revision to render, "YYYY-MM-DD".
componentsMDXRemoteProps["components"]noThe MDX component map, applied fresh on every render.

Omit revision and the component resolves it itself, as (policy.effective(new Date()) ?? policy.latest()).revision - the text that binds today, or the newest published text when nothing binds yet. That is the live-page default, computed at render time.

Render the live page

Pass revision explicitly whenever you also render metadata around the body. It keeps your title and the body on the same revision instead of letting the two resolve now separately.

// app/[locale]/legal/terms/page.tsx
import { notFound } from "next/navigation";
import { PolicyDocument } from "@daanvandenbergh/policykit/react";
import { termsPolicy } from "@/content/policies";
import { MyLink } from "@/components/MyLink"; // your locale-aware link component

export default async function TermsPage({ params }: { params: Promise<{ locale: string }> }) {
    const { locale } = await params;
    const now = new Date();
    const shown = termsPolicy.effective(now) ?? termsPolicy.latest();
    const pending = termsPolicy.pending(now);
    // The locale file's own title; shown.title is the DEFAULT locale's and would be the wrong
    // language here (content() deliberately does no fallback - the fallback is your call).
    const title = termsPolicy.content(shown.revision, locale)?.title ?? shown.title;
    // The locale segment is untrusted, and a locale introduced later is legally absent from an
    // older revision - so the live route needs the same guard the archive route does.
    if (!termsPolicy.has(shown.revision, locale)) notFound();
    return (
        <article className="prose">
            <h1>{title}</h1>
            {pending && <p>A new version takes effect on {pending.effectiveFrom}.</p>}
            <PolicyDocument
                policy={termsPolicy}
                locale={locale}
                revision={shown.revision}
                components={{ a: MyLink }}
            />
        </article>
    );
}

content(...).title is that locale file's own title:, with no default-locale fallback - otherwise you would print an English heading above Dutch text.

Serve the archive

Old revisions are the version archive, so give them a dynamic segment. Both params are untrusted, and has(revision, locale) answers the whole question in one call: it is true only when that revision exists and carries that locale. A locale introduced in 2027 is legally absent from the 2026 revisions, so a real revision plus a real locale can still be a legitimate 404.

// app/[locale]/legal/terms/[revision]/page.tsx
import { notFound } from "next/navigation";
import { PolicyDocument } from "@daanvandenbergh/policykit/react";
import { termsPolicy } from "@/content/policies";
import { MyLink } from "@/components/MyLink";

export default async function TermsRevisionPage({ params }: {
    params: Promise<{ locale: string; revision: string }>;
}) {
    const { locale, revision } = await params;
    if (!termsPolicy.has(revision, locale)) notFound();
    return (
        <PolicyDocument
            policy={termsPolicy}
            locale={locale}
            revision={revision}
            components={{ a: MyLink }}
        />
    );
}

has() never throws on a miss, and it does not read the body. Reach for revision() or content() instead only when you need the metadata or the text itself - and note that content() already returns undefined for an unknown revision, so pairing the two as guards is redundant.

Why a missed guard throws a plain Error

If the resolved (revision, locale) pair has no content, PolicyDocument throws:

Policy "terms-of-service" has no content for revision "2026-07-07" in locale "nl" - guard with
policy.revision()/policy.content() and 404 on undefined before rendering PolicyDocument.

That is a plain Error, not PolicyValidationError. The distinction is deliberate and worth matching in your own error handling: PolicyValidationError means the corpus on disk is broken and a human must fix a file. This throw means the corpus is fine and the ask was wrong - an unguarded param on either route: an unknown locale, or a locale legally absent from the revision being rendered. Guard first; the throw is only the loud backstop.

What ships, and what does not

remark-gfm is always on and cannot be turned off. Legal documents carry pipe tables - a DPA's subprocessor list, for one - and without GFM a pipe table does not fail loudly. It renders as a paragraph of literal | characters, which is content corruption in a document that is legal evidence.

Nothing else ships. No CSS, no <h1>, no "last updated" label, no prose wrapper. The component renders the body and stops, so the page shell, the styling, and the metadata line are yours.

Override MDX elements

components is the same component map MDXRemote takes, applied fresh on every render. policykit never imports next/link, so the link component is the one override nearly every app needs:

<PolicyDocument policy={termsPolicy} locale={locale} components={{ a: MyLink }} />

Map any MDX element the same way: { a: MyLink, table: MyTable, h2: MySection }.

Server components only

PolicyDocument reads the filesystem on every render. It is a server component and must never appear inside a "use client" tree - the same rule applies to PolicyBanner.

Was this page helpful?