
Next.js 16 & Supabase Headless CMS: TipTap, SSR, and AI-Era Discoverability

Stewart Moreland
The headless CMS pattern has been stable for years: a rich-text editor writes content, a database stores it, a framework renders it. What's changed is who's reading that content. Alongside human visitors and traditional search crawlers, pages are now parsed by LLM-powered agents, AI search features, and retrieval pipelines — and most of them behave very differently from Googlebot.
This post walks through a modernised stack: [1] as the rendering layer, Supabase as the data layer, and Tiptap as the editor. Along the way, I'll cover the hydration fix Tiptap needs in the App Router, where the AI toolkit actually fits (it's not where you'd guess), and what it takes to make authored content readable by systems that never run your JavaScript.
What this covers
Architecture and integration patterns, not a step-by-step tutorial. Code examples target Next.js 16.2+ and Tiptap v3. Both move fast — check the current docs before copying anything into production.
Next.js 16: what changed and why it matters for a CMS
Next.js 16 landed on 21 October 2025, ahead of Next.js Conf [2]. Two of its changes directly affect how a CMS-driven site performs and how its content gets indexed.
1. Cache Components
Cache Components is an opt-in model that combines Partial Prerendering with the use cache directive, letting you cache at the page, component, or function level. Next.js serves a static shell immediately and streams dynamic content in when it's ready [3].
Enable it at the top level of your config:
// next.config.tsimport type { NextConfig } from 'next'const nextConfig: NextConfig = {cacheComponents: true,}export default nextConfig
Don't nest this under experimental
cacheComponents sat under experimental in 16.0.x and moved to top-level in
16.1.x. The nested form now throws cacheComponents is not a valid experimental option. Enabling it also replaces experimental.ppr and the
experimental_ppr segment config, both of which were removed — a codemod
handles the mechanical part.
Once enabled, nothing is cached by default. You opt in explicitly, which is the whole point — the old implicit rules were the source of most caching confusion. A PostBody component that renders identical stored content for every visitor is a textbook candidate:
// app/posts/[slug]/post-body.tsximport { cacheLife } from 'next/cache'export async function PostBody({ slug }: { slug: string }) {'use cache'cacheLife('hours')const post = await getPostBySlug(slug)return <article dangerouslySetInnerHTML={{ __html: renderPost(post) }} />}
2. Proxy replaces Middleware
middleware.ts is deprecated in favour of proxy.ts, which makes the app's network boundary explicit and runs on the Node.js runtime [2]. The migration is a rename of both the file and its exported function, plus renamed config flags (skipMiddlewareUrlNormalize → skipProxyUrlNormalize). There's a codemod: npx @next/codemod@canary middleware-to-proxy .
// proxy.tsimport { NextRequest, NextResponse } from 'next/server'export default function proxy(request: NextRequest) {return NextResponse.redirect(new URL('/home', request.url))}
Do not put your auth here
It's tempting to guard /admin/* in the proxy. Don't. The rename exists
precisely because teams treated Middleware as an application layer, and
CVE-2025-29927 showed edge-layer auth checks could be bypassed. Proxy is for
rewrites, redirects, and headers. Authorisation belongs in your layouts, route
handlers, and — for a Supabase-backed CMS — in Row Level Security, which
enforces at the database rather than at a hop you can route around.
Why SSR matters more than it used to
A Vercel/MERJ analysis of server logs found that the major AI crawlers do not render JavaScript. GPTBot fetched JS files in roughly 11.5% of requests and ClaudeBot in roughly 23.8% — but neither executes them, so neither can read client-side rendered content [4].
Two caveats worth stating, because the "AI can't see JS" claim usually gets flattened:
- AppleBot and Gemini are exceptions. AppleBot runs a browser-based crawler; Gemini inherits Googlebot's rendering infrastructure.
- Initial-payload content still counts. Anything in the first HTML response — including JSON and streamed RSC data — can be interpreted, even if it isn't rendered markup.
The practical upshot for a CMS: render the post body server-side. An App Router setup that does this puts your content, headings, and metadata in the first response, where every crawler can reach them.
Building the editor: Tiptap in the App Router
Tiptap stores content as structured JSON — the ProseMirror document model — and exposes an extension system for custom node types [5].
Handling the hydration mismatch
Tiptap is a client-side editor that depends on browser APIs. Rendering it on the server produces markup that doesn't match what React expects during hydration. The fix is documented in Tiptap's own Next.js install guide [6]:
'use client'import { useEditor, EditorContent } from '@tiptap/react'import StarterKit from '@tiptap/starter-kit'import type { JSONContent } from '@tiptap/core'export default function TiptapEditor({ content }: { content: JSONContent }) {const editor = useEditor({extensions: [StarterKit],content,immediatelyRender: false, // required for SSR frameworks})// editor is null on the server and on first client render — always guardif (!editor) return nullreturn <EditorContent editor={editor} />}
The editor stays client-only, which is correct. A separate reader component renders stored content for visitors and runs entirely on the server.
Rendering stored JSON on the server
generateHTML has two exports and picking the wrong one fails at runtime. The one in @tiptap/core is browser-only; the server-safe version lives in @tiptap/html and uses a virtual DOM [7]:
// app/posts/[slug]/page.tsx — server componentimport { generateHTML } from '@tiptap/html'import StarterKit from '@tiptap/starter-kit'import { CustomAlert } from '@/lib/tiptap/extensions'const extensions = [StarterKit, CustomAlert]export default async function PostPage({ params }) {const { slug } = await paramsconst post = await getPostBySlug(slug)const html = generateHTML(post.content, extensions)return <article dangerouslySetInnerHTML={{ __html: html }} />}
Three things to keep straight:
- Pass the same extension array the editor used. A node type that isn't in the list is silently dropped.
generateHTMLdoesn't reproduce NodeView wrappers. If an extension adds DOM in the browser that the static renderer doesn't (tables are the common case), your server HTML and editor view will differ. Style against the static output, not the editor.- Consider
@tiptap/static-rendererinstead. It converts ProseMirror JSON to HTML, Markdown, or React elements — the React path avoidsdangerouslySetInnerHTMLentirely and lets you map custom nodes to real components.
Where the AI toolkit actually fits
Tiptap ships an AI Toolkit that lets an extension describe itself to a model via addJsonSchemaAwareness, returning a name, a description, and Zod-typed attributes that get converted to JSON Schema [8]:
import { Node } from '@tiptap/core'import { z } from 'zod'const CustomAlert = Node.create({name: 'alert',// …attributes and HTML parsing…addJsonSchemaAwareness() {return {name: 'Alert Box',description:'A highlighted box used to display important information, warnings, or tips.',attributes: {type: z.enum(['info', 'warning', 'error', 'success']).describe('The type of alert.'),},}},})
This solves a different problem than you might expect
Schema awareness exists so an AI agent writing into your editor generates
valid nodes — it's how a model knows alert accepts type="warning" rather
than inventing a <div>. It does not expose anything to search crawlers or
external retrieval pipelines; those never see it. It also isn't part of core
Tiptap: the AI Toolkit is a paid Tiptap Cloud product requiring a JWT and App
ID. If your goal is discoverability rather than AI-assisted authoring, skip
this section entirely and spend the effort on semantic HTML output instead.
The data layer: Supabase and JSONB storage
Postgres's jsonb type stores Tiptap documents natively [9], which keeps the structured document as your source of truth:
create table posts (id uuid primary key default gen_random_uuid(),slug text unique not null,title text not null,content jsonb not null,excerpt text,author_id uuid references auth.users(id),published_at timestamptz,updated_at timestamptz not null default now());create index posts_content_gin on posts using gin (content jsonb_path_ops);create index posts_published_at_idx on posts (published_at desc nulls last);
Lock it down before you ship
A Supabase table without RLS is readable and writable by anyone holding your anon key, which ships in your client bundle. Enable it in the same migration that creates the table:
alter table posts enable row level security;create policy "Published posts are public"on posts for selectusing (published_at is not null and published_at <= now());create policy "Authors manage their own posts"on posts for allusing (auth.uid() = author_id)with check (auth.uid() = author_id);
This is also why the proxy isn't your auth boundary — RLS enforces at the database, so a missed route guard doesn't leak drafts.
updated_at won't update itself
default now() fires on insert only. Add a trigger, or set the column
explicitly on every write. Silently stale dateModified values in your
structured data are a common and hard-to-spot bug.
What structured storage buys you
Storing the document rather than rendered HTML means you can:
• Render server-side with generateHTML or the static renderer
• Extract plain text for search or LLM context without parsing HTML
• Query inside the document — Postgres JSONPath handles arbitrarily nested nodes:
select slug, titlefrom postswhere jsonb_path_exists(content,'$.** ? (@.type == "alert" && @.attrs.type == "warning")');
Image handling
Store image URLs (Supabase Storage works well), not base64 blobs inside the JSONB document. Inline images bloat rows, blow past the toast threshold, and slow every query that touches the column. Configure the Tiptap Image extension to upload on insert and keep only the URL.
Making content readable: llms.txt, JSON-LD, and semantic HTML
llms.txt
llms.txt is a proposed standard for giving language models a curated, LLM-friendly map of your site. It's a Markdown file at the root path /llms.txt — not a robots.txt variant and not under /.well-known/. The spec defines a fixed order: an H1 with the project name (the only required section), a blockquote summary, optional supporting context, then ## sections containing lists of links [10].
# stewmore.dev> Practitioner-first writing on platform engineering, agentic systems,> and applied AI architecture.## Posts- [Headless CMS in 2026](/posts/headless-cms-2026.md): Next.js 16, Tiptap, and Supabase- [Agent observability with Langfuse](/posts/agent-observability.md): OpenTelemetry patterns for multi-agent systems## About- [Who I am](/about.md): Background and current work
Two things people get wrong:
- It is not a crawl-control file. There are no
Crawl-DelayorAttributiondirectives. Access control still belongs inrobots.txt; llms.txt is closer to a hand-curated sitemap for models. - It's a curated briefing, not a URL dump. Serving Markdown companions alongside your HTML pages (
/posts/slug.md) is the higher-leverage half of the pattern. Next.js does this for its own docs — every page is available as.md, indexed at/docs/llms.txt.
JSON-LD structured data
Next.js recommends rendering JSON-LD as a native <script> tag inside layout.js or page.js — next/script is for executable JavaScript, and structured data isn't that [11]. It does not go through the Metadata API's other field, which emits <meta> tags.
// app/posts/[slug]/page.tsxexport default async function PostPage({ params }) {const { slug } = await paramsconst post = await getPostBySlug(slug)const jsonLd = {'@context': 'https://schema.org','@type': 'BlogPosting',headline: post.title,description: post.excerpt,datePublished: post.published_at,dateModified: post.updated_at,author: { '@type': 'Person', name: post.author },image: [post.cover_image],mainEntityOfPage: {'@type': 'WebPage','@id': `https://example.com/posts/${post.slug}`,},}return (<><scripttype="application/ld+json"dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}/><article>{/* … */}</article></>)}
Validate the output with Google's Rich Results Test before you publish. Valid structured data doesn't guarantee rich results — it only makes you eligible.
Semantic HTML output
When you map custom Tiptap nodes to HTML, favour semantic elements over styled divs:
renderHTML({ HTMLAttributes }) {return ['aside',{ role: 'note', ...HTMLAttributes },0, // 0 → children]}
This is the least glamorous item on the list and probably the highest-value one. A role-aware <aside> survives every extraction pipeline; a <div class="callout-warning"> survives none of them.
On AI-generated summaries
If an AI feature summarises your post, it will most likely pull from your
<meta name="description">, the opening paragraphs, and your heading
structure. Clear section headings and a genuine excerpt are the most durable
optimisations you can make — and the only ones that don't depend on a
standard that might not exist in two years.
Putting it together
• Editor route (/admin/posts/[id]/edit): Tiptap client component, immediatelyRender: false, writes JSONB to Supabase under RLS
• Reader route (/posts/[slug]): server component, fetches JSONB, calls generateHTML from @tiptap/html, renders JSON-LD in a native script tag
• Cache: cacheComponents: true at the top level; use cache on the post body; invalidate on publish via a Supabase webhook hitting revalidateTag
• Auth: RLS policies in Postgres, checked again in layouts and route handlers — never in proxy.ts
• Discoverability: /llms.txt plus Markdown companions, JSON-LD per page, semantic HTML output
Because the source of truth is structured JSONB, you can add a Markdown endpoint for AI context windows, a search pipeline that extracts headings, or an ActivityPub feed later — without touching the editor.
Where this is heading
The pressure on CMS architecture isn't new editor features. It's the expectation that content be machine-readable in several formats at once: HTML for browsers, schema.org for search, clean Markdown for model context, and eventually tool-callable endpoints for agents.
Worth being honest about the uncertainty, though. llms.txt is a proposal, adoption by the major labs is uneven, and it may not survive. Tiptap's AI toolkit is a paid product solving an authoring problem, not a distribution one. The durable bets are the boring ones: server-render your content, use semantic elements, keep your metadata accurate, and store the structured document rather than the rendered output. Those hold regardless of which standard wins.
Next steps
- Audit what a non-rendering crawler sees —
curl -A "GPTBot" https://yoursite.com/posts/your-slugand read the raw HTML, not the DevTools Elements panel. - Run
npx @next/codemod@canary middleware-to-proxy .and move any auth logic out of the proxy layer while you're in there. - Check RLS is enabled on every content table. Supabase's dashboard flags unprotected tables — believe it.
- Ship
/llms.txtand one Markdown companion page. Measure before you build the pipeline.
If you're working through this on a real codebase and hit something that doesn't match the docs, I'd like to hear about it — the gap between released and documented is wider than usual right now.