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:
- normalize the incoming Next route params with
getPayloadMarkdownDocsRoutePath - call
resolvePayloadMarkdownDocsRoute - render
PayloadMarkdownDocsPagewhen a docs route matches - fall back to your existing Pages query and renderer
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:
- exact generated docs records
- docs set index routes
- docs group index routes when
pageModeisauto nullfor 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.