Next.js (App Router)
A complete integration — fetch a post, render the block body, stamp SEO + JSON-LD, sync redirects in middleware, report 404s back to Clog.
This walks you through a production-shaped Next.js 16 App Router integration. By the end you'll have:
- A typed Clog client (
lib/clog.ts) — fetch with the right header, parse errors, returnnullon 404. - A post page at
/posts/[slug]— fetches at request time, renders 13 block types, stamps SEO meta + JSON-LD. - An index page at
/posts— paginated, sortable. - Edge middleware that replays redirects.
- A
not-found.tsxthat reports the 404 back to Clog.
You can adapt every piece to Pages Router, Remix, SvelteKit, Astro, or anything else — the patterns transfer cleanly.
0. Setup
Add the key as a server-only env var. Never NEXT_PUBLIC_* it.
CLOG_API_KEY=clog_live_xxx
CLOG_API_BASE=https://api.clog.devAdd the host to your Next config so next/image can serve Clog-hosted media:
import type { NextConfig } from 'next';
const config: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'storage.googleapis.com' }, // Clog media storage
// ...plus whichever host your Clog deployment serves media from
],
},
};
export default config;1. A typed Clog client
One module that owns headers, base URL, and error normalisation. Everything else calls into this.
const BASE = process.env.CLOG_API_BASE!;
const KEY = process.env.CLOG_API_KEY!;
type ClogError = { code: string; message: string; details?: unknown };
async function clog<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}/api/v1/external${path}`, {
...init,
headers: {
'X-API-Key': KEY,
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
// Default to a sensible cache for content endpoints.
// Override with `{ cache: 'no-store' }` on the caller for drafts / previews.
next: { revalidate: 60, ...(init as { next?: object })?.next },
});
if (res.status === 404) {
throw Object.assign(new Error('Not found'), { code: 'NOT_FOUND', status: 404 });
}
if (!res.ok) {
const err: ClogError = await res.json().catch(() => ({ code: 'INTERNAL', message: res.statusText }));
throw Object.assign(new Error(`Clog ${err.code}: ${err.message}`), {
code: err.code,
details: err.details,
status: res.status,
});
}
return res.json() as Promise<T>;
}
/** Returns the resource, or `null` if 404. Other errors bubble. */
async function clogOrNull<T>(path: string, init?: RequestInit): Promise<T | null> {
try { return await clog<T>(path, init); }
catch (e) {
if ((e as { code?: string }).code === 'NOT_FOUND') return null;
throw e;
}
}
// Typed accessors.
import type { PostResponse, PaginatedResponse } from './clog-types';
export const Clog = {
getPost: (slug: string) => clogOrNull<PostResponse>(`/posts/${encodeURIComponent(slug)}`),
listPosts: (q: { page?: number; pageSize?: number; status?: string } = {}) =>
clog<PaginatedResponse<PostResponse>>(`/posts?${new URLSearchParams({
status: 'published',
page: String(q.page ?? 1),
pageSize: String(q.pageSize ?? 10),
order: 'desc',
orderBy: 'publishedAt',
...(q.status ? { status: q.status } : {}),
})}`),
listRedirects: () =>
clog<PaginatedResponse<RedirectResponse>>(`/redirects?pageSize=500&order=asc&orderBy=fromPath`),
reportBrokenLink: (path: string, referrer?: string) =>
clog<unknown>(`/link-health`, {
method: 'POST',
body: JSON.stringify({ path, ...(referrer ? { referrer } : {}) }),
}).catch(() => { /* fire-and-forget */ }),
getWorkspace: () => clog<WorkspaceResponse>('/workspace'),
};PostResponse, PaginatedResponse<T>, RedirectResponse, WorkspaceResponse, and Block are types you author once from the endpoint reference. Generating them from the OpenAPI spec with openapi-typescript works well too.
2. The post page
A single dynamic route: fetch the post, stamp <head> via generateMetadata, render the body and the structuredBlocks projection, drop jsonLd for crawlers.
import { notFound } from 'next/navigation';
import { Clog } from '@/lib/clog';
import { renderBlock } from '@/components/blocks';
import type { Metadata } from 'next';
type Params = { slug: string };
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { slug } = await params;
const post = await Clog.getPost(slug);
if (!post) return {};
const { seo, title, excerpt } = post;
return {
title: seo.title ?? title,
description: seo.description ?? excerpt ?? undefined,
alternates: seo.canonicalUrl ? { canonical: seo.canonicalUrl } : undefined,
robots: {
index: seo.robots.index,
follow: seo.robots.follow,
},
openGraph: {
title: seo.og.title ?? seo.title ?? title,
description: seo.og.description ?? seo.description ?? excerpt ?? undefined,
type: (seo.og.type as 'article') ?? 'article',
},
twitter: {
card: seo.twitter.card ?? 'summary_large_image',
title: seo.twitter.title ?? seo.title ?? title,
description: seo.twitter.description ?? seo.description ?? excerpt ?? undefined,
creator: seo.twitter.creator,
},
};
}
export default async function PostPage({ params }: { params: Promise<Params> }) {
const { slug } = await params;
const post = await Clog.getPost(slug);
if (!post) notFound();
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(post.jsonLd) }}
/>
<article>
<header>
<h1>{post.title}</h1>
{post.author && (
<p>
By {post.author.displayName} · {post.readingTimeMin} min read
</p>
)}
</header>
{post.featuredMedia?.url && (
<img src={post.featuredMedia.url} alt={post.featuredMedia.alt ?? ''} />
)}
{/* The author-written body */}
{post.bodyJson.map((block, i) => renderBlock(block, i))}
{/* The structured-content projection — recipe card, how-to steps, etc. */}
{post.structuredBlocks.length > 0 && (
<section aria-label={post.schemaType}>
{post.structuredBlocks.map((block, i) => renderBlock(block, `s-${i}`))}
</section>
)}
{post.relatedPosts.length > 0 && (
<aside>
<h2>Related</h2>
<ul>
{post.relatedPosts.map(r => (
<li key={r.id}><a href={`/posts/${r.slug}`}>{r.title}</a></li>
))}
</ul>
</aside>
)}
</article>
</>
);
}structuredBlocks is rendered with the same renderBlock — see step 3.
3. A block renderer
A discriminated union switch. Cover all 13 types; TypeScript's exhaustiveness check stops you from forgetting one.
import Image from 'next/image';
import { marked } from 'marked';
import type { Block } from '@/lib/clog-types';
function Inline({ children }: { children: string }) {
// parseInline → only inline marks, no <p> wrappers
return <span dangerouslySetInnerHTML={{ __html: marked.parseInline(children) }} />;
}
export function renderBlock(block: Block, key: number | string) {
switch (block.type) {
case 'paragraph':
return <p key={key}><Inline>{block.text}</Inline></p>;
case 'heading': {
const Tag = `h${block.level}` as 'h2' | 'h3' | 'h4';
return <Tag key={key} id={block.id}>{block.text}</Tag>;
}
case 'list':
return block.style === 'ordered'
? <ol key={key}>{block.items.map((it, i) => <li key={i}><Inline>{it}</Inline></li>)}</ol>
: <ul key={key}>{block.items.map((it, i) => <li key={i}><Inline>{it}</Inline></li>)}</ul>;
case 'image': {
if (!block.url) return null;
return (
<figure key={key}>
<img src={block.url} alt={block.alt ?? ''} loading="lazy" />
{block.caption && <figcaption>{block.caption}</figcaption>}
</figure>
);
}
case 'code':
return (
<pre key={key}>
<code className={`language-${block.language}`}>{block.code}</code>
</pre>
);
case 'quote':
return (
<blockquote key={key}>
<Inline>{block.text}</Inline>
{block.attribution && <cite>{` — ${block.attribution}`}</cite>}
</blockquote>
);
case 'divider':
return <hr key={key} />;
case 'callout':
return (
<aside key={key} data-variant={block.variant}>
{block.title && <strong>{block.title}</strong>}
<p><Inline>{block.body}</Inline></p>
</aside>
);
case 'video_embed':
return (
<div key={key} className="video-embed">
{/* Render your player wrapper of choice */}
<iframe src={block.url} title={block.title ?? ''} allowFullScreen />
</div>
);
case 'tweet_embed':
return <blockquote key={key} className="twitter-tweet"><a href={block.url} /></blockquote>;
case 'faq':
return (
<dl key={key}>
{block.items.map((it, i) => (
<div key={i}>
<dt>{it.question}</dt>
<dd>{it.answer}</dd>
</div>
))}
</dl>
);
case 'cta':
return (
<a key={key} href={block.linkUrl} data-variant={block.variant} className="cta">
<strong>{block.title}</strong>
<p><Inline>{block.body}</Inline></p>
<span>{block.linkLabel}</span>
</a>
);
case 'key_takeaways':
return (
<aside key={key} aria-label="Key takeaways">
<ul>{block.items.map((it, i) => <li key={i}>{it}</li>)}</ul>
</aside>
);
default: {
const _exhaustive: never = block;
return null;
}
}
}Sanitize the Inline HTML if you don't fully trust your editors. marked.parseInline does not strip arbitrary HTML — pair it with sanitize-html or DOMPurify before injecting via dangerouslySetInnerHTML.
4. The index page
import { Clog } from '@/lib/clog';
export const revalidate = 60;
export default async function PostsIndex({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page = '1' } = await searchParams;
const result = await Clog.listPosts({ page: Number(page), pageSize: 10 });
return (
<main>
<h1>Posts</h1>
<ul>
{result.data.map(p => (
<li key={p.id}>
<a href={`/posts/${p.slug}`}>{p.title}</a>
<p>{p.excerpt}</p>
</li>
))}
</ul>
<nav>
{Number(page) > 1 && <a href={`/posts?page=${Number(page) - 1}`}>Newer</a>}
{Number(page) < result.totalPages && <a href={`/posts?page=${Number(page) + 1}`}>Older</a>}
</nav>
</main>
);
}5. Redirects in middleware
Pull the table on cold start (or build), match in middleware, replay the redirect with the correct status.
import { NextRequest, NextResponse } from 'next/server';
let redirects: { fromPath: string; toPath: string; type: string }[] | null = null;
const STATUS: Record<string, number> = {
Permanent301: 301,
Temporary302: 302,
Temporary307: 307,
Gone410: 410,
UnavailableForLegalReasons451: 451,
};
async function loadRedirects() {
if (redirects) return redirects;
const res = await fetch(
`${process.env.CLOG_API_BASE}/api/v1/external/redirects?pageSize=500&order=asc&orderBy=fromPath`,
{ headers: { 'X-API-Key': process.env.CLOG_API_KEY! }, next: { revalidate: 300 } },
);
const json = (await res.json()) as { data: { fromPath: string; toPath: string; type: string }[] };
redirects = json.data;
return redirects;
}
export async function middleware(req: NextRequest) {
const rules = await loadRedirects();
const hit = rules.find(r => r.fromPath === req.nextUrl.pathname);
if (!hit) return NextResponse.next();
const status = STATUS[hit.type] ?? 301;
if (status === 410 || status === 451) {
return new NextResponse(null, { status });
}
const dest = hit.toPath.startsWith('http')
? new URL(hit.toPath)
: new URL(hit.toPath, req.nextUrl.origin);
return NextResponse.redirect(dest, status);
}
export const config = { matcher: '/((?!_next|api|favicon.ico).*)' };For higher traffic, build the redirect table at deploy time into a static lookup instead — same shape, no per-cold-start fetch.
6. Report 404s back
When your not-found.tsx renders, fire-and-forget a report so editors see the broken link in the dashboard.
import { headers } from 'next/headers';
import { Clog } from '@/lib/clog';
export default async function NotFound() {
// Use the request path the route file landed on.
const h = await headers();
const path = h.get('x-invoke-path') ?? h.get('referer') ?? '/';
const referrer = h.get('referer') ?? undefined;
// Fire-and-forget — don't block the 404 page on reporting.
Clog.reportBrokenLink(path, referrer);
return (
<main>
<h1>Not found</h1>
<p>That page isn't here. Try the <a href="/posts">posts index</a>.</p>
</main>
);
}Wire the same call into your edge middleware if you also serve 404s before reaching the app — wherever your site decides "this is a 404," report it.
7. Stamping site-level publisher info
The workspace endpoint feeds your <head> site-wide:
import { Clog } from '@/lib/clog';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const ws = await Clog.getWorkspace();
return (
<html lang={ws.defaultLocale ?? 'en'}>
<head>
<meta property="og:site_name" content={ws.siteName ?? ws.name} />
{ws.twitterSite && <meta name="twitter:site" content={ws.twitterSite} />}
{ws.siteVerification?.google && (
<meta name="google-site-verification" content={ws.siteVerification.google} />
)}
{ws.defaultOgImageUrl && (
<meta property="og:image" content={ws.defaultOgImageUrl} />
)}
</head>
<body>{children}</body>
</html>
);
}What's still your call
- Caching strategy.
revalidate: 60on per-post fetches is fine for marketing sites; ISR + on-demand revalidation via a webhook (when you add one) is the upgrade. Build-time SSG works for low-edit-cadence sites — switchclog()to useforce-cacheand call it fromgenerateStaticParams. - Image optimisation.
next/imageworks once the host is inremotePatterns. Worth doing for high-traffic pages; raw<img>is fine for low. - Markdown safety. Sanitize before injecting
<Inline>HTML if you allow untrusted authors.