Valkyrian Labs logo

Route Adapter

Resolve and render docs routes from a Next/Payload route layer.

Route Adapter

The /next export lets an existing Next slug route render generated docs before falling back to normal Pages rendering. The plugin reads generated docs, docs sets, and docs groups; it does not mutate your Pages collection or create public frontend routes for you.

The usual pattern is:

  1. normalize the incoming Next route params with getPayloadMarkdownDocsRoutePath
  2. call resolvePayloadMarkdownDocsRoute
  3. render PayloadMarkdownDocsPage when a docs route matches
  4. fall back to your existing Pages query and renderer
Prefer one catch-all route when possible

If your Pages route can be changed, a single [[...slug]] route is the simplest integration. It can render top-level Pages, nested Pages, docs group indexes, docs set indexes, and generated docs pages from one place.

1import { notFound } from 'next/navigation'2import { getPayload } from 'payload'3import config from '@payload-config'4 5import {6  PayloadMarkdownDocsPage,7  getPayloadMarkdownDocsRoutePath,8  resolvePayloadMarkdownDocsRoute,9} from '@valkyrianlabs/payload-markdown-docs/next'10 11export const dynamic = 'force-dynamic'12 13export default async function Page({14  params,15}: {16  params: Promise<{ slug?: string[] }>17}) {18  const { slug = [] } = await params19  const path = getPayloadMarkdownDocsRoutePath({ path: slug })20  const payload = await getPayload({ config })21 22  const resolved = await resolvePayloadMarkdownDocsRoute({23    payload,24    path,25  })26 27  if (resolved) {28    return <PayloadMarkdownDocsPage resolved={resolved} />29  }30 31  // Replace this with your normal Pages collection lookup.32  const page = await queryPageByPath({ path })33 34  if (page) {35    return <RenderPage page={page} />36  }37 38  notFound()39}

queryPageByPath and RenderPage are placeholders for your app's existing Pages loader and renderer.

path accepts a normalized route string, a single [slug] string, or a [...slug] / [[...slug]] string array. slug remains supported on resolvePayloadMarkdownDocsRoute, but path is clearer for new integrations.

Existing Slug Routes

If your site already has app/(frontend)/[slug]/page.tsx, call the resolver at the top of that route before querying Pages. This covers top-level docs routes such as /plugins or /payload-markdown-docs.

Generated docs are usually nested below a docs set route base, so a one-segment [slug] route cannot match every docs page. Add a catch-all route such as app/(frontend)/[...slug]/page.tsx, or replace the Pages route with one app/(frontend)/[[...slug]]/page.tsx route when that is feasible.

Use the same resolver-first flow in both route files:

1const resolved = await resolvePayloadMarkdownDocsRoute({2  payload,3  path,4  includeDrafts: draft,5})6 7if (resolved) {8  return <PayloadMarkdownDocsPage resolved={resolved} />9}

Then run your normal Pages fallback. In a flat Pages collection, that may be a lookup by slug. In a path-based Pages collection, use the normalized path string.

With Nested Docs Pages

When your Pages collection uses @payloadcms/plugin-nested-docs, query fallback Pages by their full route path instead of only the final slug. A common approach is to store a fullPath field from the nested-docs breadcrumbs:

1import type { CollectionBeforeChangeHook } from 'payload'2 3import type { Page } from '@/payload-types'4 5export const populateFullPath: CollectionBeforeChangeHook<Page> = ({ data }) => {6  const url = data?.breadcrumbs?.at(-1)?.url7 8  if (url) {9    data.fullPath = url10  }11 12  return data13}

Add fullPath as an indexed field on Pages and populate it before change. Your catch-all route can then resolve docs first and fall back to:

1const result = await payload.find({2  collection: 'pages',3  limit: 1,4  pagination: false,5  where: {6    fullPath: {7      equals: path,8    },9  },10})

This keeps docs routing and nested Page routing independent. The docs adapter does not need the Pages fullPath field; only your fallback Pages query does.

Caching

Production App Router pages can otherwise cache generated docs output. The sync endpoint revalidates generated docs paths after successful writes, but dynamic = 'force-dynamic' is the simplest option when the app prefers always fresh docs reads.

Metadata

In generateMetadata, resolve docs first and fall back to the Pages metadata helper only when the route is not a docs route:

1import type { Metadata } from 'next'2 3import {4  generatePayloadMarkdownDocsMetadata,5  getPayloadMarkdownDocsRoutePath,6} from '@valkyrianlabs/payload-markdown-docs/next'7 8export async function generateMetadata({9  params,10}: {11  params: Promise<{ slug?: string[] }>12}): Promise<Metadata> {13  const { slug = [] } = await params14  const path = getPayloadMarkdownDocsRoutePath({ path: slug })15  const payload = await getPayload({ config })16 17  const docsMetadata = await generatePayloadMarkdownDocsMetadata({18    payload,19    path,20  })21 22  if (docsMetadata) {23    return docsMetadata24  }25 26  const page = await queryPageByPath({ path })27 28  return generatePageMetadata({ page })29}

Resolution Order

The helper resolves:

  1. exact generated docs records
  2. docs set index routes
  3. docs group index routes when pageMode is auto
  4. null for normal fallback routes

See metadata, dynamic sitemap, and sidebar.

Agent Skill Files

The route adapter is for rendered human docs pages. It does not serve raw .txt or .md AI assets.

Native agent skill artifacts live outside generated docs records under skills/<source>/<agent>/. When pmdocs push syncs those files, the plugin stores them as raw asset records and serves them through asset handlers such as /plugins/payload-markdown-docs/skills/codex, /plugins/payload-markdown-docs/skills/codex/SKILL.md, and /plugins/payload-markdown-docs/skills/codex.zip. The extensionless agent route is a generated Markdown directory index; raw files remain under /skills/<agent>/<path...>.

In a Next App Router app, public raw asset URLs need filesystem route files that delegate to the asset handlers:

1pmdocs install routes --payload-app "src/app/(payload)"

If public asset route files are missing, the frontend catch-all may return rendered 404 HTML even though /api/... asset URLs work. The /api/... routes are implementation/internal fallback routes, not the public canonical URLs.