You're reading docs for Nextly Alpha. APIs may change between releases.

Guides

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, so every read is publish-state aware, access aware, and cached with F1 tag-based ISR.

SEO data (the metaTitle / metaDescription / ogImage / canonical / noindex fields) is opt-in via the agnostic @nextlyhq/plugin-seo 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.

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.

import { resolveContent } from "nextly/runtime";
import { notFound } from "next/navigation";

const post = await resolveContent("posts", slug, { depth: 2 });
if (!post) notFound();

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:

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

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). A rule-less (public) collection still renders; a collection with a stored member-only or role-based read rule 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.

// Anonymous public read (default): stored access rules are 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).
await resolveContent("posts", slug, { overrideAccess: true, status: "all" });

An anonymous read enforces stored rules that deny outright (public/authenticated/role-based). A row-level constraint rule (owner-only, or a custom rule returning a query) and inline defineCollection({ access }) code rules need a user context to evaluate, so they are not applied for an anonymous read. Gate that content 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 stored read-policy change doesn't write an entry). So a public site that wants cached pages should read its public content with overrideAccess: true; access-gated or member content runs fresh per request.

Other options: 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:

// 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:

// 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 rule-less collection still renders; a stored member-only or role-based one 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:

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:

const route = createContentRoute({
  collections: ["pages"],
  draft: async ({ collection }) => {
    const scope = await readPreviewScope(previewConfig);
    if (scope === null || 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.

export async function generateMetadata({ params }) {
  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.

// 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:

// 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.