Search documentation

Find pages, sections, and snippets across the Clog docs.

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, return null on 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.tsx that 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.

.env.local
CLOG_API_KEY=clog_live_xxx
CLOG_API_BASE=https://api.clog.dev

Add the host to your Next config so next/image can serve Clog-hosted media:

next.config.ts
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.

lib/clog.ts
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.

app/posts/[slug]/page.tsx
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.

components/blocks.tsx
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

app/posts/page.tsx
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.

middleware.ts
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.

app/not-found.tsx
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:

app/layout.tsx
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: 60 on 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 — switch clog() to use force-cache and call it from generateStaticParams.
  • Image optimisation. next/image works once the host is in remotePatterns. Worth doing for high-traffic pages; raw <img> is fine for low.
  • Markdown safety. Sanitize before injecting <Inline> HTML if you allow untrusted authors.

On this page