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.
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.
Install the SDK
The
pmkinpackage wraps the delivery API with typed methods.server-onlymakes sure the client, and with it your key, never ends up in a browser bundle.npm install pmkin server-onlyAdd your key
Put the delivery key in
.env.local. Don't prefix it withNEXT_PUBLIC_: it should only be read on the server..env.localPMKIN_API_KEY=your_delivery_keyCreate the client
One client for the whole app is enough.
lib/pmkin.tsimport 'server-only'import { PmkinClient } from 'pmkin'const token = process.env.PMKIN_API_KEYif (!token) {throw new Error('PMKIN_API_KEY is not set. Add it to .env.local.')}export const pmkin = new PmkinClient({ token })List your documents
listDocuments()returns published documents without their content, so it stays light. Exportrevalidateto regenerate the page in the background (ISR) instead of on every request.app/blog/page.tsximport Link from 'next/link'import { pmkin } from '@/lib/pmkin'// Rebuild the list at most once a minute.export const revalidate = 60export 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>)}Render a document
Look the document up by its slug.
findDocumentBySlugresolves toundefinedfor a slug that doesn't exist or isn't published, so callnotFound()to answer with a real 404.generateStaticParamsprerenders every published document at build time.app/blog/[slug]/page.tsximport type { Metadata } from 'next'import { notFound } from 'next/navigation'import { cache } from 'react'import { pmkin } from '@/lib/pmkin'export const revalidate = 60interface 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 paramsconst 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 paramsconst 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:
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.tsimport { 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()andlistDocumentsInCategory(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.