# Routing and SEO

Turn content into pages with correct metadata, sitemap, and robots. The nextly/runtime helpers resolve a slug to a published, access-enforced entry, build a Next.js Metadata object from an seo field group, and deliver sitemap.xml and robots.txt.

Nextly ships a small set of Next.js helpers for the last mile of a content
site: resolve a URL to the right entry, build its `<head>` metadata, and serve
`sitemap.xml` / `robots.txt`. They live in `nextly/runtime` and read your
content through the [Direct API](https://nextlyhq.com/docs/api-reference/direct-api.md), so every read
is publish-state aware, access aware, and cached with
[F1 tag-based ISR](https://nextlyhq.com/docs/guides/isr-caching.md).

SEO **data** (the `metaTitle` / `metaDescription` / `ogImage` / `canonical` /
`noindex` fields) is opt-in via the agnostic
[`@nextlyhq/plugin-seo`](https://nextlyhq.com/docs/plugins.md) plugin; the **behavior** below is
Next-only and lives in `nextly/runtime`. Nothing here forces `next` onto a
headless or admin-only deployment — every `next` import is type-only and
`next/navigation` is resolved lazily.

**Which Nextly they read through.** `resolveContent`, `createContentRoute` and
`createPublicContentRoute` read content, and take a `nextly` option. Without it, they use the instance already started in this process
and throws "Nextly services not initialized" if there is none, which is the case
on a new project's first public request. Pass the instance from
`await getNextly({ config })` (`getNextly` from `nextly`, `config` from your
`nextly.config.ts`) as `nextly`. The samples below leave it out for brevity.

## resolveContent — slug to a published entry

`resolveContent(collection, slug, options?)` resolves a URL slug to a single
entry and returns `null` on a genuine miss (render `notFound()`), rethrowing a
transient read error so a DB blip never bakes a permanently-cached 404.

```ts
import { resolveContent, type ContentEntry } from "nextly/runtime";
import { notFound } from "next/navigation";

// `ContentEntry` is `Record<string, unknown>`: the entry is whatever the
// collection holds, so read fields off it deliberately rather than assuming a
// shape. `nextly generate:types` is what turns this into a named type.
function Post({ entry }: { entry: ContentEntry }) {
  return <article>{String(entry.title ?? "")}</article>;
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await resolveContent("posts", slug, { depth: 2 });
  if (!post) notFound();
  return <Post entry={post} />;
}
```

**Publish state.** By default `resolveContent` reads `status: "published"`.
This is the lifecycle-aware scope, not a `where` clause on a `status` column, so
it also constrains a localized collection's per-locale companion status — a
draft translation under a published main row is never returned. On a status-less
collection (no built-in Draft/Published lifecycle) the scope is a no-op and
every row is live, so you can mix lifecycle and status-less collections freely.

**Pending edits.** Publish state is only half the picture. An entry that has
never been published is covered by `status`, but pending edits to an
**already-published** entry are stored as a separate working draft that no
`status` scope can see — the live row keeps serving until you publish. Pass
`draft: true` to read those instead:

```ts
// The page as the editor last saved it, not as visitors see it.
await resolveContent("posts", slug, {
  draft: true,
  overrideAccess: true,
  trustedCollections: ["posts"],
});
```

The two layers are gated differently, and that difference is the important part:

* **Pending edits** are judged per row, by an update-capability probe, so asking
  for them is safe from anywhere. A caller who cannot edit the document gets the
  published row instead.
* **Never-published entries** are judged by nothing — the query simply returns
  them. So `draft: true` widens `status` to `"all"` **only on a trusted read**
  (`overrideAccess: true`). Without that, a draft read still sees published rows
  and overlays their pending edits.

That split exists because a `draft` flag wired from an untrusted request would
otherwise publish unpublished pages. An explicit `status` always wins.

A draft read is **never cached**, because cache tags are busted by writes to the
live row while a draft changes on every save.

**Access.** By default `resolveContent` **enforces** the collection's read
policy (`overrideAccess: false`) — the code-defined `access.read` on its
`defineCollection` config. A collection that declares none still renders; one
whose rule refuses an anonymous caller is hidden from an unauthenticated request
and resolves to `null` (→ `notFound()`, so the URL does not reveal that the
entry exists). Pass a `user` to render member content, or `overrideAccess: true`
for a fully trusted read.

```ts
// Anonymous public read (default): the collection's access rule is enforced.
await resolveContent("posts", slug);

// Render member-only content for a signed-in reader.
await resolveContent("posts", slug, { user: { id, role } });

// Trusted read that ignores access rules (e.g. a preview route you gate yourself).
// `trustedCollections` is required with `overrideAccess: true`: it names the
// collections the trust reaches as relationships are expanded. Others are read
// as a visitor would read them. `TRUSTS_EVERY_COLLECTION` (from "nextly")
// trusts all of them.
await resolveContent("posts", slug, {
  overrideAccess: true,
  trustedCollections: ["posts"],
  status: "all",
});
```

> An anonymous read hands the collection's `access.read` rule a real anonymous
> context (`user: null`, no roles, no permissions) and lets it decide, so a
> rule like `read: ({ user }) => !!user` hides the content as written, and a
> rule that admits only by role or permission refuses the anonymous reader by
> construction. Content meant for a signed-in audience belongs behind an
> authenticated read (pass a `user`) rather than the anonymous default.

**Caching.** Only a **trusted** (`overrideAccess: true`) read with no `user` is
F1-cached — an enforced read is never cached, because its result depends on an
access decision that a content-tag bust can't invalidate (a rule can answer
differently without any entry being written). So a public site that wants cached pages should
read its public content with `overrideAccess: true` and the `trustedCollections`
it may reach; access-gated or member content runs fresh per request.

Other options: `nextly` (above), `trustedCollections` (required with
`overrideAccess: true`), `slugField` (default `"slug"`), `depth`, `locale`,
`richTextFormat`, `tags` (extra cache tags for populated relations),
`revalidate` (a time-based safety net for cached reads), and `cacheScope` (a
discriminator when distinct readers — per tenant or database — resolve the same
slug).

## createContentRoute — the optional catch-all

For the pages-collection model (add an `/about` entry and it just works), wire
one `app/[[...slug]]/page.tsx` optional catch-all:

```tsx
// app/[[...slug]]/page.tsx
import { createContentRoute, buildMetadata } from "nextly/runtime";

const route = createContentRoute({
  collections: ["pages"],
  render: entry => <Page entry={entry} />,
  buildMetadata: entry => buildMetadata(entry),
});

export const generateMetadata = route.generateMetadata;
export default route.ContentPage;
```

`createContentRoute` reads **access-enforced** content: the collections' read
rules decide, so the answer depends on who is asking and the page renders per
request. It deliberately returns no `generateStaticParams` — Next classifies a
route as static because that export exists, and a route that must render
dynamically cannot also claim to be static.

When the content in those collections is public, say so and the route
pre-renders:

```tsx
// app/[[...slug]]/page.tsx
import { createPublicContentRoute, buildMetadata } from "nextly/runtime";

const route = createPublicContentRoute({
  collections: ["pages"],
  render: entry => <Page entry={entry} />,
  buildMetadata: entry => buildMetadata(entry),
});

export const generateStaticParams = route.generateStaticParams;
export const generateMetadata = route.generateMetadata;
export const dynamicParams = true;
export default route.ContentPage;
```

A public route reads trusted, so its reads are cacheable and its paths
pre-renderable. It refuses `draft` and `staticParamsLimit: 0` at construction —
both would force the render dynamic while the route still told Next it was
static. Use `createContentRoute` for preview or for a site that pre-renders
nothing.

Both factories resolve a path across the configured collections (first match
wins) and call `notFound()` on a genuine miss or a reserved path (`/admin`,
`/api`, `/_next`, `/static`, and metadata files like `sitemap.xml`). `status`
behaves as in `resolveContent`.

**Which factory you call decides whether access rules are consulted at all, so
choose it per collection set, not per preference.**

* `createContentRoute` reads **enforced**. A collection with no `access.read`
  still renders; one whose rule refuses an anonymous caller is hidden from
  these anonymous requests.
* `createPublicContentRoute` reads **trusted** — access rules are not consulted,
  on the resolved read and on the `generateStaticParams` scan alike. That is
  what makes its reads cacheable and its paths pre-renderable, and it means
  **every collection you list must be public**. A restricted collection listed
  here has its entries read trusted and its paths pre-rendered into public HTML.
  That statement is now exactly true: the collections you list are the ones
  trusted, and a relationship reaching past them is read enforced unless you
  name it (see below).

### What a populated relationship is read as

A page often populates a relationship — a post reaching its author, a page
reaching its hero image. That target is a collection you did **not** list: it
was reached through a field.

**`createPublicContentRoute` reads those targets as a visitor would**, unless
you say otherwise. Their own access rules apply, and only their published rows
come back. Name the ones your pages populate to have them read trusted too:

```ts
export const { ContentPage, generateMetadata, generateStaticParams } =
  createPublicContentRoute({
    collections: ["posts"],
    // Posts populate an author; authors are public too.
    trustedCollections: ["posts", "authors"],
    depth: 1,
    render: (entry) => <Post entry={entry} />,
  });
```

`trustedCollections` defaults to the `collections` you listed, so a route that
populates nothing needs no extra config, and one that does gets an explicit
decision rather than an inherited one.

> **Trusting a collection does not admit its drafts.** A public route
> pre-renders, so an unpublished row pulled in through a relationship is written
> into a static artifact and stays there after the row is unpublished —
> unpublishing cannot take back a page already built. Trust decides *who* may
> read a row; being published decides whether it is ready for anyone. Nothing in
> this config widens a lifecycle.

`createContentRoute` needs none of this for its ordinary reads — it reads
enforced already, and **its previews trust nothing by default.** A draft grant
turns the bypass on for one request, but it authorizes ONE document and says
nothing about what that document points at — including a sibling row in the
same collection. So a preview populates relations exactly as an anonymous
visitor would see them. Name a target in `trustedCollections` if you know it is
public and want it populated in preview too.

> **Cached pages are point-in-time copies.** A public route pre-renders, so a
> target whose read policy tightens after the build is not reflected until that
> page is revalidated — and the same is true of the page's own content, bound or
> not. Name the related collections in `tags` so a write to one busts the page,
> and use `revalidate` as a time-based safety net for a policy change, which
> writes no row and therefore busts no tag.

**`createPublicContentRoute` defaults to `depth: 0`** — no relation expansion at
all — so a route that never populates anything cannot be surprised by any of
this. Set `depth` when your pages do populate relations.

**The route factories take no `overrideAccess` option** — unlike
`resolveContent`, which still does, because a page calling it directly knows who
is asking. Two settings that had to agree is what produced a route behaving
differently depending on whether the database had rows in it when the build ran;
for a route, the posture is now the name of the function you call.

This route always resolves **anonymously** — its config is captured once at
module scope, so it cannot carry a per-request user. For a route that renders
per-visitor member content, call `resolveContent` with the request's `user`
inside your own page rather than using `createContentRoute`.

### Previewing drafts

Whether a visitor is previewing is a per-request fact, and route config is not —
so `draft` takes a function the route asks on every request. Wire it to Next's
draft mode:

The function is handed the collection and slug being resolved, so the answer can
be scoped to a document:

```ts
import { createContentRoute, readPreviewScope } from "nextly/runtime";

const route = createContentRoute({
  collections: ["pages"],
  draft: async ({ collection }) => {
    const scope = await readPreviewScope();
    // A Single's token names no collection, so it cannot grant this route's draft.
    if (scope === null || !("collection" in scope)) return false;
    if (scope.collection !== collection) return false;
    // Name the entry, do not just answer yes: slugs are not unique.
    return { entryId: scope.entryId };
  },
  render: page => <Page {...page} />,
});
```

Return `{ entryId }` rather than `true` whenever a token is backing the
decision. A slug need not be unique — the resolver supports duplicates and
settles them by sorting on `id` — so a bare `true` grants whichever row the
route happens to resolve, which may not be the one the token named. With an
`entryId` the route discards the draft when the path resolved elsewhere.

**Use that argument.** Next's draft mode is a single boolean for the whole host:
`draftMode().isEnabled` tells you a visitor opened *a* valid preview link, never
*which* document it was for. Answering from it alone turns a link scoped to one
unpublished page into a key to every unpublished page in the configured
collections, for the life of the session — which is exactly what a preview
token's scope exists to prevent.

**Returning `true` is an authorization decision, not a display preference.** The
route resolves anonymously and the working draft is only shown to a caller who
could edit the document, so a request this returns `true` for is read trusted.
Put the check inside that function — a verified preview cookie, a session — and
never in a query parameter a visitor controls.

`draft` belongs to `createContentRoute` only. `createPublicContentRoute` refuses
it at construction: a draft read is never cacheable, so it marks the render
dynamic — while the public route's `generateStaticParams` tells Next the route
is static, and a dynamic marking inside a static render is an error. Preview and
pre-rendering are not compatible on one route; mount previewable paths on a
dynamic one.

A literal `draft: true` is accepted for a route mounted behind your own auth. It
means **every** visitor sees unpublished content, which is almost never what a
public site wants.

> **Next 16 Cache Components:** an exported `generateStaticParams` must return at
> least one entry under Cache Components. For a no-prerender or empty-site setup
> in that mode, export only `ContentPage` and `generateMetadata` and let paths
> render on demand.

Not every blog needs this: if you already have typed per-type routes
(`/blog/[slug]`, `/authors/[slug]`), keep them and use `resolveContent` +
`buildMetadata` inside each — `createContentRoute` is for the single-catch-all
pages model.

## buildMetadata — the seo group to Next Metadata

`buildMetadata(entry, options?)` turns the plugin's `seo` field group into a
Next.js `Metadata` object (title, description, canonical, OpenGraph, Twitter
card, and `robots` from `noindex`). Blank `seo` fields fall back to the values
you pass; caller `openGraph` / `twitter` extras win.

```ts
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  if (!post) return {};
  return buildMetadata(post, {
    fallback: {
      title: post.title,
      description: post.excerpt,
      image: post.featuredImage?.url,
      canonical: `/blog/${slug}`,
    },
    openGraph: { type: "article", publishedTime: post.publishedAt },
  });
}
```

A `canonical` is validated to a root-relative path or an absolute http(s) URL
(a `mailto:`/`javascript:` value falls back), and a page ships `index, follow`
unless `seo.noindex` is set.

## nextlySitemap and nextlyRobots

`nextlySitemap({ entries, tags })` builds the default export for
`app/sitemap.ts` from a caller-supplied `entries` provider and caches it with
F1. Pass each collection's `nextlyTags(...)` so a publish or delete busts
`/sitemap.xml` in lockstep with the pages. With no tags and no `revalidate` it
reads uncached (and marks the render dynamic) so it never freezes stale.

```ts
// app/sitemap.ts
import { nextlySitemap, nextlyTags } from "nextly/runtime";
import { buildSitemapUrls } from "@nextlyhq/plugin-seo";

export default nextlySitemap({
  tags: nextlyTags("posts"),
  entries: async () =>
    (await buildSitemapUrls(services, { collections: ["posts"], baseUrl })).map(
      u => ({ url: u.loc, lastModified: u.lastModified })
    ),
});
```

`nextlyRobots({ sitemap })` builds `app/robots.ts`, keeping `/admin` and `/api`
out of the index on a path boundary (so `/administration` is not swallowed) and
advertising the sitemap:

```ts
// app/robots.ts
import { nextlyRobots } from "nextly/runtime";
export default nextlyRobots({ sitemap: "https://example.com/sitemap.xml" });
```

## Reference: the blog template

The `blog` template dogfoods these helpers: `app/robots.ts` is `nextlyRobots`,
`app/sitemap.ts` is `nextlySitemap` over the published post/category/tag/author
slugs, and the post detail page's `generateMetadata` is `buildMetadata` reading
the Posts `seo` group. Its per-type typed routes keep `resolveContent`-style
reads inside each page rather than a single catch-all.
