Framework guides

Next.js (App Router)

Render PMKIN documents in a Next.js App Router site with server components.

Next.js server components can fetch content right where they render it, which makes PMKIN a natural fit: no client-side fetching, no API keys in the browser and pages that rebuild themselves when you publish. In ten minutes you'll have a /blog list and a page for every published document.

Before you start

You need a Next.js 15 app with the App Router and a delivery key. Create the key in PMKIN on your project's API page.

  1. Install the SDK

    The pmkin package wraps the delivery API with typed methods. server-only makes sure the client, and with it your key, never ends up in a browser bundle.

    npm install pmkin server-only
  2. Add your key

    Put the delivery key in .env.local. Don't prefix it with NEXT_PUBLIC_: it should only be read on the server.

    .env.local
    PMKIN_API_KEY=your_delivery_key
  3. Create the client

    One client for the whole app is enough.

    lib/pmkin.ts
    import 'server-only'
    import { PmkinClient } from 'pmkin'
    const token = process.env.PMKIN_API_KEY
    if (!token) {
    throw new Error('PMKIN_API_KEY is not set. Add it to .env.local.')
    }
    export const pmkin = new PmkinClient({ token })
  4. List your documents

    listDocuments() returns published documents without their content, so it stays light. Export revalidate to regenerate the page in the background (ISR) instead of on every request.

    app/blog/page.tsx
    import Link from 'next/link'
    import { pmkin } from '@/lib/pmkin'
    // Rebuild the list at most once a minute.
    export const revalidate = 60
    export default async function BlogPage() {
    const documents = await pmkin.listDocuments()
    return (
    <main>
    <h1>Blog</h1>
    {documents.map((document) => (
    <article key={document.id}>
    <h2>
    <Link href={`/blog/${document.slug}`}>{document.title}</Link>
    </h2>
    <p>{document.excerpt}</p>
    </article>
    ))}
    </main>
    )
    }
  5. Render a document

    Look the document up by its slug. findDocumentBySlug resolves to undefined for a slug that doesn't exist or isn't published, so call notFound() to answer with a real 404. generateStaticParams prerenders every published document at build time.

    app/blog/[slug]/page.tsx
    import type { Metadata } from 'next'
    import { notFound } from 'next/navigation'
    import { cache } from 'react'
    import { pmkin } from '@/lib/pmkin'
    export const revalidate = 60
    interface PageProps {
    params: Promise<{ slug: string }>
    }
    // generateMetadata and the page share one request per render.
    const findDocument = cache((slug: string) =>
    pmkin.findDocumentBySlug(decodeURIComponent(slug))
    )
    export async function generateStaticParams() {
    const documents = await pmkin.listDocuments()
    return documents.map((document) => ({ slug: document.slug }))
    }
    export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
    const { slug } = await params
    const document = await findDocument(slug)
    if (!document) {
    return { title: 'Not found' }
    }
    return {
    title: document.metaTitle || document.title,
    description: document.metaDescription || document.excerpt,
    openGraph: document.coverImage
    ? { images: [document.coverImage.url] }
    : undefined
    }
    }
    export default async function BlogPostPage({ params }: PageProps) {
    const { slug } = await params
    const document = await findDocument(slug)
    if (!document) {
    notFound()
    }
    return (
    <article className="prose">
    <h1>{document.title}</h1>
    {document.subtitle ? <p className="lead">{document.subtitle}</p> : null}
    <div dangerouslySetInnerHTML={{ __html: document.html }} />
    </article>
    )
    }

HTML or markdown?#

Every document comes as both html and markdown. PMKIN sanitizes the HTML (scripts, iframes, event handlers and inline styles are removed), so rendering it with dangerouslySetInnerHTML is safe and the fastest way to get going. Style it with the Tailwind Typography plugin's prose class or your own CSS.

If you want to swap elements for your own components, like next/image for images or a highlighted code block, render the markdown instead:

components/document-body.tsx
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
export function DocumentBody({ markdown }: { markdown: string }) {
return <Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>
}

Caching and publishing#

With revalidate = 60, a newly published document shows up within a minute without a rebuild. To show it immediately, add a route that clears the cache and call it after publishing:

app/api/revalidate/route.ts
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
export async function POST(request: Request) {
if (request.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
revalidatePath('/blog', 'layout')
return Response.json({ revalidated: true })
}

The delivery API allows 25 requests a second per IP address. Large static builds stay well below that because generateStaticParams fetches the list once. See rate limits.

Next steps#

  • Group posts by category with listCategories() and listDocumentsInCategory(categoryId). See queries.
  • Preview drafts with includeDrafts. See pagination and drafts.
  • Handle a revoked key or rate limiting gracefully. See errors.
  • Using the Pages Router? Follow the Pages Router guide.