
Building a Next.js 15 App with a Supabase Headless CMS

Stewart Moreland
A blog on Next.js plus Supabase is two problems disguised as one. The App Router story is straightforward now: Server Components, the Metadata API, and file conventions give you HTML-first pages without reinventing the wheel [1]. The Supabase story is where teams get surprised—session refresh across the server boundary, Row Level Security that has to stay maintainable, and an admin surface that must respect both.
This guide wires Shadcn UI, Supabase (Postgres, auth, storage), and a Portable Text-shaped body format so you can ship a blog CMS you own end to end—posts, authors, and optional categories—not a full marketing-site content graph unless you choose to grow into one.
The practical implication: treat where the session is refreshed (in Next.js 16 that is usually proxy.ts) as part of your architecture, not an afterthought. Supabase’s troubleshooting guide for Next.js calls this out explicitly [2].
What You'll Build
By the end of this guide, you'll have a production-ready Next.js 16 blog stack with:
- Shadcn UI and Tailwind for the public site and admin
- Supabase auth, Postgres, and RLS for posts and taxonomy
- An admin-style authoring surface and Portable Text rendering
- SSG/SSR and revalidation patterns suited to publishing workflows
What to know before you build (Next.js 16)
Next.js 16 changes the tooling and request model more than the core idea of Server Components. Skim this once so you do not paint yourself into a corner [3].
Turbopack by default. next dev and next build use Turbopack unless you opt out. A custom Webpack configuration will fail the build until you migrate, adjust config, or run next build --webpack.
middleware → proxy. Prefer proxy.ts exporting proxy for request interception. The proxy runtime is Node.js, not the Edge runtime. If you still need Edge, you can keep middleware.ts for now and watch the release notes—Next documents both paths during the transition [3].
Async request APIs. In layouts, pages, generateMetadata, and route handlers, params and searchParams are Promises. Always await them before use [3].
Revalidation. revalidateTag accepts an optional second argument (a cacheLife profile). When a webhook must guarantee readers never see stale HTML, compare with updateTag in the current caching docs [4].
Images and patches. Stay on the latest patched next release. If you feed local URLs with query strings into next/image (for example cache-busted CMS paths), you need images.localPatterns in next.config [3].
Common gaps worth checking
metadatain Client Components. Themetadataexport andgenerateMetadataonly work in Server Components. If the file starts with'use client', Next.js ignores them—same class of bug as the Metadata API discussion in practical SEO work [5].- Supabase client packages. Use
@supabase/supabase-jswith@supabase/ssrand the current cookie helpers (getAll/setAll). Do not start new projects on@supabase/auth-helpers-nextjs; maintenance focus is on@supabase/ssr[6]. - RLS that is too clever. Policies run per query. Keep them readable and index the columns they reference.
- Cache Components. If you enable
cacheComponents, Partial Prerendering behaves differently than it did on Next.js 15 canaries—read the migration guide before flipping flags [7].
Project Setup: Next.js 16 with Tailwind and Shadcn UI
Set up the UI stack step by step.
The practical implication: after shadcn init, keep the Tailwind layout your template generated. Greenfield Shadcn installs often land on Tailwind CSS v4 (@tailwindcss/postcss, CSS-first tokens). Older tutorials show a single large tailwind.config.ts; that is still valid on many codebases, but it is not the only shape anymore.
Step 1: Initialize the Next.js app
npx create-next-app@latest my-app --tailwind --typescript --appcd my-app
The --tailwind --typescript --app flags ensure you get Tailwind CSS, TypeScript support, and the App Router configured out of the box.
Step 2: Add Shadcn/UI
Shadcn UI is a collection of copy-pastable, themeable components built on Tailwind CSS and Radix UI primitives.
npx shadcn@latest init
When prompted, choose your preferred configuration:
- Style: Default or New York
- Base color: Your brand color
- CSS variables: Yes (recommended)
Step 3: Install Essential UI Components
npx shadcn@latest add button input card dialog sheetnpx shadcn@latest add dropdown-menu avatar badge
Step 4: Tailwind and Shadcn paths
shadcn init wires Tailwind content paths and theme tokens for you. On Tailwind v4, much of that lives in CSS and PostCSS; on v3 you still see a tailwind.config file with tailwindcss-animate and CSS variable–backed colors.
If you maintain a classic config, the important part is that content includes everywhere you place components:
import type { Config } from 'tailwindcss'const config: Config = {content: ['./app/**/*.{ts,tsx}','./components/**/*.{ts,tsx}','./src/**/*.{ts,tsx}',],// theme, plugins: match what shadcn init added for your major version}export default config
If your initializer already created postcss.config with @tailwindcss/postcss and theme variables in globals.css, prefer that output over pasting a full v3-era config from an old article.
Foundation Complete
With Next.js 16, Tailwind, and Shadcn UI in place, you have an accessible UI foundation and a consistent place to add components as the CMS grows.
Setting Up Supabase as your blog backend
Why Supabase for a blog?
Supabase is Postgres plus auth, storage, and realtime—enough to replace a hosted headless CMS for many blogs. You keep SQL, migrations, and RLS in your repo. As a blog backend, it gives you:
- Full Schema Control: Create and modify tables freely
- PostgreSQL Power: Advanced queries, triggers, and functions
- Built-in Auth: Row Level Security and user management
- Real-time Updates: Live content synchronization
- Edge Functions: Serverless compute at the edge
The practical implication: Supabase is not “just a database”—RLS is your CMS permissions model. If a rule is missing here, no amount of UI gating fixes it.
1. Create a Supabase Project and connect Next.js
Create Your Supabase Project
- Sign up at supabase.com and create a new project
- Navigate to Settings → API to find your credentials
- Copy your Project URL and Anon Public Key
Configure Environment Variables
NEXT_PUBLIC_SUPABASE_URL=your_project_urlNEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_keySUPABASE_SERVICE_ROLE_KEY=your_service_role_key
Never commit your .env.local file to version control. Add it to your .gitignore file.
Install Supabase dependencies
npm install @supabase/supabase-js @supabase/ssr
Use @supabase/ssr for cookie-based sessions in the App Router. Legacy @supabase/auth-helpers-nextjs should not be the default choice for new work [6].
Configure Supabase clients (browser, Server Components, proxy)
import { createBrowserClient } from '@supabase/ssr'export function createClient() {return createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!,process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,)}
Authentication state
The browser client handles interactive auth; the server factory reads the same cookies in Server Components; proxy refreshes expired sessions before those components run. If you still need the Edge runtime, you can keep middleware.ts until your hosting story catches up—Next documents the migration path [3].
The practical implication: without a proxy (or legacy middleware) refresh, you will see “logged out” flashes or failed server loads even when the browser still has a refresh token [2].
2. Designing a blog database schema
Most Supabase-backed blogs need the same spine: people (profiles tied to auth), posts (body as JSONB if you use Portable Text or block JSON), and taxonomy (categories, and optionally tags later). The exact columns are yours to trim—this is a template, not a mandate.
The practical implication: start with the smallest schema that matches your editorial model. You can always add tables (tags, series, newsletters) once a real requirement shows up.
Core tables
-- Profiles extending Supabase Auth (authors + admin roles)CREATE TABLE profiles (id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,display_name TEXT,avatar_url TEXT,bio TEXT,role TEXT CHECK (role IN ('admin', 'editor', 'reader')) DEFAULT 'reader',created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());-- Posts: slug for URLs, status for drafts, JSONB for rich bodyCREATE TABLE posts (id UUID DEFAULT gen_random_uuid() PRIMARY KEY,title TEXT NOT NULL,slug TEXT UNIQUE NOT NULL,excerpt TEXT,content JSONB, -- e.g. Portable Text or your block formatcover_image_url TEXT,meta_description TEXT,published_at TIMESTAMP WITH TIME ZONE,author_id UUID REFERENCES profiles(id) ON DELETE SET NULL,status TEXT CHECK (status IN ('draft', 'published', 'archived')) DEFAULT 'draft',created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());-- Categories (optional section/topic taxonomy)CREATE TABLE categories (id UUID DEFAULT gen_random_uuid() PRIMARY KEY,name TEXT UNIQUE NOT NULL,slug TEXT UNIQUE NOT NULL,description TEXT,color TEXT DEFAULT '#6366f1',created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());-- Many-to-many: posts ↔ categoriesCREATE TABLE post_categories (post_id UUID REFERENCES posts(id) ON DELETE CASCADE,category_id UUID REFERENCES categories(id) ON DELETE CASCADE,PRIMARY KEY (post_id, category_id));
Database Features & Benefits
UUIDs provide better distributed systems compatibility and prevent enumeration attacks compared to sequential integers.
JSONB columns allow flexible, searchable content structure perfect for Portable Text while maintaining query performance.
CHECK constraints for status fields ensure data integrity at the database level.
Performance Optimizations
-- Indexes for common blog queriesCREATE INDEX idx_posts_status_published_at ON posts(status, published_at)WHERE status = 'published';CREATE INDEX idx_posts_slug ON posts(slug) WHERE status = 'published';CREATE INDEX idx_profiles_role ON profiles(role);CREATE INDEX idx_posts_author ON posts(author_id);-- GIN index for JSONB body search (tune opclass if you use jsonb_path_ops)CREATE INDEX idx_posts_content_gin ON posts USING GIN(content);
Automatic Profile Creation
-- Function to handle new user creationCREATE OR REPLACE FUNCTION handle_new_user()RETURNS TRIGGER AS $$BEGININSERT INTO profiles (id, display_name, role)VALUES (NEW.id, NEW.raw_user_meta_data->>'full_name', 'reader');RETURN NEW;END;$$ LANGUAGE plpgsql SECURITY DEFINER;-- Trigger for automatic profile creationCREATE TRIGGER on_auth_user_createdAFTER INSERT ON auth.usersFOR EACH ROW EXECUTE FUNCTION handle_new_user();-- Function to update timestampsCREATE OR REPLACE FUNCTION update_updated_at_column()RETURNS TRIGGER AS $$BEGINNEW.updated_at = NOW();RETURN NEW;END;$$ LANGUAGE plpgsql;-- Apply to all tables with updated_atCREATE TRIGGER update_profiles_updated_atBEFORE UPDATE ON profilesFOR EACH ROW EXECUTE FUNCTION update_updated_at_column();CREATE TRIGGER update_posts_updated_atBEFORE UPDATE ON postsFOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Why this shape works for blogs
JSONB carries whatever your editor outputs; RLS (next section) is where you enforce who sees drafts; UUIDs avoid predictable IDs in public APIs. Add tags, related posts, or static pages as separate tables when you need them—not by default.
3. Supabase Authentication and RBAC for Admin/Editor Roles
Supabase Auth provides built-in user management with powerful Row Level Security (RLS) for fine-grained access control.
Implementing Row Level Security Policies
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;ALTER TABLE categories ENABLE ROW LEVEL SECURITY;ALTER TABLE post_categories ENABLE ROW LEVEL SECURITY;ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;-- Anonymous and logged-in readers: published posts onlyCREATE POLICY "Public posts are viewable by everyone" ON postsFOR SELECT USING (status = 'published');-- Staff: full CRUD on posts (drafts, scheduling, etc.)CREATE POLICY "Staff manage posts" ON postsFOR ALLUSING (EXISTS (SELECT 1 FROM profilesWHERE profiles.id = auth.uid()AND profiles.role IN ('admin', 'editor')))WITH CHECK (EXISTS (SELECT 1 FROM profilesWHERE profiles.id = auth.uid()AND profiles.role IN ('admin', 'editor')));-- Taxonomy: public read, staff writeCREATE POLICY "Categories are readable" ON categoriesFOR SELECT USING (true);CREATE POLICY "Staff manage categories" ON categoriesFOR ALLUSING (EXISTS (SELECT 1 FROM profilesWHERE profiles.id = auth.uid()AND profiles.role IN ('admin', 'editor')))WITH CHECK (EXISTS (SELECT 1 FROM profilesWHERE profiles.id = auth.uid()AND profiles.role IN ('admin', 'editor')));-- Junction: public only sees links for published posts; staff sees allCREATE POLICY "Post categories for published posts" ON post_categoriesFOR SELECT USING (EXISTS (SELECT 1 FROM postsWHERE posts.id = post_categories.post_idAND posts.status = 'published'));CREATE POLICY "Staff manage post categories" ON post_categoriesFOR ALLUSING (EXISTS (SELECT 1 FROM profilesWHERE profiles.id = auth.uid()AND profiles.role IN ('admin', 'editor')))WITH CHECK (EXISTS (SELECT 1 FROM profilesWHERE profiles.id = auth.uid()AND profiles.role IN ('admin', 'editor')));CREATE POLICY "Users can view own profile" ON profilesFOR SELECT USING (auth.uid() = id);CREATE POLICY "Users can update own profile" ON profilesFOR UPDATE USING (auth.uid() = id);CREATE POLICY "Admins manage profiles" ON profilesFOR ALLUSING (EXISTS (SELECT 1 FROM profilesWHERE profiles.id = auth.uid()AND profiles.role = 'admin'))WITH CHECK (EXISTS (SELECT 1 FROM profilesWHERE profiles.id = auth.uid()AND profiles.role = 'admin'));
Role-based Access Control Helper Functions
-- Helper function to check if user has specific roleCREATE OR REPLACE FUNCTION user_has_role(required_role TEXT)RETURNS BOOLEAN AS $$BEGINRETURN EXISTS (SELECT 1 FROM profilesWHERE id = auth.uid()AND role = required_role);END;$$ LANGUAGE plpgsql SECURITY DEFINER;-- Helper function to check if user is admin or editorCREATE OR REPLACE FUNCTION user_can_edit()RETURNS BOOLEAN AS $$BEGINRETURN EXISTS (SELECT 1 FROM profilesWHERE id = auth.uid()AND role IN ('admin', 'editor'));END;$$ LANGUAGE plpgsql SECURITY DEFINER;-- Helper function to get user roleCREATE OR REPLACE FUNCTION get_user_role()RETURNS TEXT AS $$SELECT role FROM profiles WHERE id = auth.uid();$$ LANGUAGE sql SECURITY DEFINER;
RLS policies are evaluated for every query, so keep them simple and use indexes on columns referenced in policies for optimal performance.
Proxy-based route protection (Next.js 16)
proxy runs on the Node.js runtime. Use it to refresh the session and enforce admin/API rules before Server Components render.
import { createServerClient } from '@supabase/ssr'import { NextResponse, type NextRequest } from 'next/server'export async function proxy(request: NextRequest) {let supabaseResponse = NextResponse.next({request: { headers: request.headers },})const supabase = createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!,process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,{cookies: {getAll() {return request.cookies.getAll()},setAll(cookiesToSet) {cookiesToSet.forEach(({ name, value }) =>request.cookies.set(name, value),)supabaseResponse = NextResponse.next({request: { headers: request.headers },})cookiesToSet.forEach(({ name, value, options }) =>supabaseResponse.cookies.set(name, value, options),)},},},)const {data: { user },} = await supabase.auth.getUser()if (request.nextUrl.pathname.startsWith('/admin')) {if (!user) {const loginUrl = new URL('/auth/login', request.url)loginUrl.searchParams.set('redirectTo', request.nextUrl.pathname)return NextResponse.redirect(loginUrl)}// Check user role with cachingconst { data: profile } = await supabase.from('profiles').select('role').eq('id', user.id).single()if (!profile || !['admin', 'editor'].includes(profile.role)) {return NextResponse.redirect(new URL('/unauthorized', request.url))}// Add user context to headers for server componentssupabaseResponse.headers.set('x-user-role', profile.role)supabaseResponse.headers.set('x-user-id', user.id)}// Protect API routesif (request.nextUrl.pathname.startsWith('/api/admin')) {if (!user) {return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })}const { data: profile } = await supabase.from('profiles').select('role').eq('id', user.id).single()if (!profile || !['admin', 'editor'].includes(profile.role)) {return NextResponse.json({ error: 'Forbidden' }, { status: 403 })}}return supabaseResponse}export const config = {matcher: [/** Match all request paths except for the ones starting with:* - _next/static (static files)* - _next/image (image optimization files)* - favicon.ico (favicon file)* - public assets*/'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',],}
What this proxy adds
- Redirect preservation: stores the original URL for post-login redirection
- Role-based API protection: secures admin API endpoints
- Header context: optional pass-through of role and user id to downstream handlers
- Matcher: skips static assets so you are not running auth on every image request
4. Supabase Storage for Media
Supabase Storage provides S3-compatible object storage. Create buckets for different media types:
-- Create storage bucketsINSERT INTO storage.buckets (id, name, public) VALUES('uploads', 'uploads', true),('avatars', 'avatars', true);-- Set up storage policiesCREATE POLICY "Public Access" ON storage.objects FOR SELECT USING (bucket_id = 'uploads');CREATE POLICY "Authenticated users can upload" ON storage.objects FOR INSERTWITH CHECK (bucket_id = 'uploads' AND auth.role() = 'authenticated');
Modern file upload pattern with React:
// components/admin/file-uploader.tsximport { createClient } from '@/lib/supabase/client'import { useState } from 'react'export function FileUploader({onUpload,}: {onUpload: (url: string) => void}) {const [uploading, setUploading] = useState(false)const supabase = createClient()const uploadFile = async (event: React.ChangeEvent<HTMLInputElement>) => {try {setUploading(true)const file = event.target.files?.[0]if (!file) returnconst fileExt = file.name.split('.').pop()const fileName = `${Math.random()}.${fileExt}`const filePath = `uploads/${fileName}`const { error: uploadError } = await supabase.storage.from('uploads').upload(filePath, file)if (uploadError) throw uploadErrorconst {data: { publicUrl },} = supabase.storage.from('uploads').getPublicUrl(filePath)onUpload(publicUrl)} catch (error) {console.error('Error uploading file:', error)} finally {setUploading(false)}}return (<inputtype="file"onChange={uploadFile}disabled={uploading}accept="image/*,video/*,.json"/>)}
Building the blog admin and editor
The admin area is where editors draft and publish posts (and maintain categories if you exposed them). Build the shell from Server Components, rich inputs as client components, and Shadcn UI for layout and forms.
The practical implication: duplicate the lightest possible auth check in the layout (redirect if anonymous) and keep heavier policy in RLS—never rely on UI alone.
Admin route protection with Server Components
Create an admin layout at /app/admin/layout.tsx that handles authentication and role checking:
// app/admin/layout.tsximport { createClient } from '@/lib/supabase/server'import { redirect } from 'next/navigation'import { Sidebar } from '@/components/admin/sidebar'export default async function AdminLayout({children,}: {children: React.ReactNode}) {const supabase = await createClient()const {data: { user },error,} = await supabase.auth.getUser()if (error || !user) {redirect('/auth/login')}const { data: profile } = await supabase.from('profiles').select('role, display_name').eq('id', user.id).single()if (!profile || !['admin', 'editor'].includes(profile.role)) {redirect('/unauthorized')}return (<div className="flex h-screen bg-gray-50 dark:bg-gray-900"><Sidebar user={user} profile={profile} /><main className="flex-1 overflow-auto"><div className="container mx-auto p-6">{children}</div></main></div>)}
Content Creation Interface with Modern Shadcn UI
Build forms using the latest Shadcn UI patterns with React Hook Form and Zod validation:
// components/admin/post-form.tsx'use client'import { zodResolver } from '@hookform/resolvers/zod'import { useForm } from 'react-hook-form'import * as z from 'zod'import { Button } from '@/components/ui/button'import {Form,FormControl,FormField,FormItem,FormLabel,FormMessage,} from '@/components/ui/form'import { Input } from '@/components/ui/input'import { Textarea } from '@/components/ui/textarea'import {Select,SelectContent,SelectItem,SelectTrigger,SelectValue,} from '@/components/ui/select'import { PortableTextEditor } from './portable-text-editor'const postSchema = z.object({title: z.string().min(1, 'Title is required'),slug: z.string().min(1, 'Slug is required'),excerpt: z.string().min(1, 'Excerpt is required'),content: z.any(), // Portable Text structurestatus: z.enum(['draft', 'published', 'archived']),meta_description: z.string().max(160, 'Meta description should be under 160 characters'),})type PostFormValues = z.infer<typeof postSchema>export function PostForm({initialData,}: {initialData?: Partial<PostFormValues>}) {const form = useForm<PostFormValues>({resolver: zodResolver(postSchema),defaultValues: initialData || {title: '',slug: '',excerpt: '',content: [],status: 'draft',meta_description: '',},})const onSubmit = async (values: PostFormValues) => {// Handle form submissionconsole.log(values)}return (<Form {...form}><form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"><FormFieldcontrol={form.control}name="title"render={({ field }) => (<FormItem><FormLabel>Title</FormLabel><FormControl><Input placeholder="Enter post title..." {...field} /></FormControl><FormMessage /></FormItem>)}/><FormFieldcontrol={form.control}name="slug"render={({ field }) => (<FormItem><FormLabel>Slug</FormLabel><FormControl><Input placeholder="post-slug" {...field} /></FormControl><FormMessage /></FormItem>)}/><FormFieldcontrol={form.control}name="content"render={({ field }) => (<FormItem><FormLabel>Content</FormLabel><FormControl><PortableTextEditorvalue={field.value}onChange={field.onChange}/></FormControl><FormMessage /></FormItem>)}/><FormFieldcontrol={form.control}name="status"render={({ field }) => (<FormItem><FormLabel>Status</FormLabel><Select onValueChange={field.onChange} defaultValue={field.value}><FormControl><SelectTrigger><SelectValue placeholder="Select status" /></SelectTrigger></FormControl><SelectContent><SelectItem value="draft">Draft</SelectItem><SelectItem value="published">Published</SelectItem><SelectItem value="archived">Archived</SelectItem></SelectContent></Select><FormMessage /></FormItem>)}/><Button type="submit" className="w-full">Save Post</Button></form></Form>)}
Modern Block Editor Implementation
Create a flexible block editor for Portable Text using modern React patterns:
// components/admin/portable-text-editor.tsx'use client'import { useState } from 'react'import { Button } from '@/components/ui/button'import { Card, CardContent, CardHeader } from '@/components/ui/card'import { Textarea } from '@/components/ui/textarea'import { Input } from '@/components/ui/input'import {Select,SelectContent,SelectItem,SelectTrigger,SelectValue,} from '@/components/ui/select'import { Trash2, Plus, MoveUp, MoveDown } from 'lucide-react'type BlockType = 'paragraph' | 'heading' | 'image' | 'video' | 'code' | 'quote'interface Block {id: stringtype: BlockTypecontent: any}export function PortableTextEditor({value,onChange,}: {value: Block[]onChange: (blocks: Block[]) => void}) {const addBlock = (type: BlockType) => {const newBlock: Block = {id: crypto.randomUUID(),type,content: getDefaultContent(type),}onChange([...value, newBlock])}const updateBlock = (id: string, content: any) => {onChange(value.map(block => (block.id === id ? { ...block, content } : block)),)}const deleteBlock = (id: string) => {onChange(value.filter(block => block.id !== id))}const moveBlock = (id: string, direction: 'up' | 'down') => {const index = value.findIndex(block => block.id === id)if (index === -1) returnconst newIndex = direction === 'up' ? index - 1 : index + 1if (newIndex < 0 || newIndex >= value.length) returnconst newBlocks = [...value];[newBlocks[index], newBlocks[newIndex]] = [newBlocks[newIndex],newBlocks[index],]onChange(newBlocks)}return (<div className="space-y-4">{value.map((block, index) => (<Card key={block.id} className="relative"><CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"><span className="text-sm font-medium capitalize">{block.type}</span><div className="flex gap-1"><Buttontype="button"variant="ghost"size="sm"onClick={() => moveBlock(block.id, 'up')}disabled={index === 0}><MoveUp className="h-4 w-4" /></Button><Buttontype="button"variant="ghost"size="sm"onClick={() => moveBlock(block.id, 'down')}disabled={index === value.length - 1}><MoveDown className="h-4 w-4" /></Button><Buttontype="button"variant="ghost"size="sm"onClick={() => deleteBlock(block.id)}><Trash2 className="h-4 w-4" /></Button></div></CardHeader><CardContent><BlockEditorblock={block}onChange={content => updateBlock(block.id, content)}/></CardContent></Card>))}<div className="flex gap-2"><Select onValueChange={(type: BlockType) => addBlock(type)}><SelectTrigger className="w-48"><SelectValue placeholder="Add block..." /></SelectTrigger><SelectContent><SelectItem value="paragraph">Paragraph</SelectItem><SelectItem value="heading">Heading</SelectItem><SelectItem value="image">Image</SelectItem><SelectItem value="video">Video</SelectItem><SelectItem value="code">Code</SelectItem><SelectItem value="quote">Quote</SelectItem></SelectContent></Select></div></div>)}function BlockEditor({block,onChange,}: {block: BlockonChange: (content: any) => void}) {switch (block.type) {case 'paragraph':return (<Textareavalue={block.content.text || ''}onChange={e => onChange({ text: e.target.value })}placeholder="Enter paragraph text..."rows={4}/>)case 'heading':return (<div className="space-y-2"><Selectvalue={block.content.level || 'h2'}onValueChange={level => onChange({ ...block.content, level })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="h1">Heading 1</SelectItem><SelectItem value="h2">Heading 2</SelectItem><SelectItem value="h3">Heading 3</SelectItem></SelectContent></Select><Inputvalue={block.content.text || ''}onChange={e => onChange({ ...block.content, text: e.target.value })}placeholder="Enter heading text..."/></div>)case 'image':return (<div className="space-y-2"><Inputvalue={block.content.url || ''}onChange={e => onChange({ ...block.content, url: e.target.value })}placeholder="Image URL..."/><Inputvalue={block.content.alt || ''}onChange={e => onChange({ ...block.content, alt: e.target.value })}placeholder="Alt text..."/></div>)default:return null}}function getDefaultContent(type: BlockType) {switch (type) {case 'paragraph':return { text: '' }case 'heading':return { level: 'h2', text: '' }case 'image':return { url: '', alt: '' }case 'video':return { url: '' }case 'code':return { code: '', language: 'javascript' }case 'quote':return { text: '', author: '' }default:return {}}}
Portable Text Rendering with React
Use the latest @portabletext/react patterns for rendering content:
// components/portable-text-renderer.tsximport { PortableText, PortableTextReactComponents } from '@portabletext/react'import Image from 'next/image'import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'const components: Partial<PortableTextReactComponents> = {types: {image: ({ value }) => (<div className="my-6"><Imagesrc={value.url}alt={value.alt || ''}width={800}height={400}className="rounded-lg"/>{value.caption && (<p className="text-sm text-gray-600 mt-2 text-center">{value.caption}</p>)}</div>),code: ({ value }) => (<div className="my-6"><SyntaxHighlighterlanguage={value.language || 'javascript'}style={oneDark}customStyle={{borderRadius: '0.5rem',padding: '1rem',}}>{value.code}</SyntaxHighlighter></div>),video: ({ value }) => (<div className="my-6"><iframesrc={value.url}width="100%"height="400"frameBorder="0"allowFullScreenclassName="rounded-lg"/></div>),},block: {h1: ({ children }) => (<h1 className="text-4xl font-bold mb-4">{children}</h1>),h2: ({ children }) => (<h2 className="text-3xl font-semibold mb-3">{children}</h2>),h3: ({ children }) => (<h3 className="text-2xl font-medium mb-2">{children}</h3>),normal: ({ children }) => (<p className="mb-4 leading-relaxed">{children}</p>),blockquote: ({ children }) => (<blockquote className="border-l-4 border-gray-300 pl-4 my-6 italic text-gray-700">{children}</blockquote>),},marks: {strong: ({ children }) => (<strong className="font-semibold">{children}</strong>),em: ({ children }) => <em className="italic">{children}</em>,code: ({ children }) => (<code className="bg-gray-100 px-1 py-0.5 rounded text-sm">{children}</code>),},}export function PortableTextRenderer({ content }: { content: any }) {return (<div className="prose prose-lg max-w-none dark:prose-invert"><PortableText value={content} components={components} /></div>)}
Static generation, SSR, and revalidation (Next.js 16)
Next.js 16 keeps the same mental model—SSG and SSR are still about when data is read—but params and searchParams are async everywhere they show up on the server [3].
The practical implication: every dynamic segment in this post uses await params so copy-paste matches what next build expects on v16.
Static site generation (SSG) with ISR
Use a basic Supabase client (anon key, no cookie session) inside generateStaticParams when published posts are public; otherwise you are coupling static paths to whoever is logged in.
// app/blog/[slug]/page.tsximport { createBasicClient, createClient } from '@/lib/supabase/server'import { PortableTextRenderer } from '@/components/portable-text-renderer'import { notFound } from 'next/navigation'export async function generateStaticParams() {const supabase = createBasicClient()const { data: posts } = await supabase.from('posts').select('slug').eq('status', 'published')return posts?.map(post => ({ slug: post.slug })) || []}export async function generateMetadata({params,}: {params: Promise<{ slug: string }>}) {const { slug } = await paramsconst supabase = await createClient()const { data: post } = await supabase.from('posts').select('title, excerpt, meta_description').eq('slug', slug).eq('status', 'published').single()if (!post) return {}return {title: post.title,description: post.meta_description || post.excerpt,}}export default async function BlogPost({params,}: {params: Promise<{ slug: string }>}) {const { slug } = await paramsconst supabase = await createClient()const { data: post } = await supabase.from('posts').select(`title,content,published_at,author:profiles(display_name, avatar_url)`,).eq('slug', slug).eq('status', 'published').single()if (!post) notFound()return (<article className="max-w-4xl mx-auto py-8"><header className="mb-8"><h1 className="text-4xl font-bold mb-4">{post.title}</h1><div className="flex items-center gap-4 text-gray-600"><span>By {post.author?.display_name}</span><time dateTime={post.published_at}>{new Date(post.published_at).toLocaleDateString()}</time></div></header><PortableTextRenderer content={post.content} /></article>)}// ISR: revalidate at most once per hourexport const revalidate = 3600
If you do not expose createBasicClient yet, add a helper that instantiates @supabase/supabase-js with the anon key only (no cookies). Use that for generateStaticParams and other build-time reads; use the createClient() factory that wraps @supabase/ssr when you need the user session.
Server-side rendering for dynamic admin views
// app/admin/posts/page.tsximport { createClient } from '@/lib/supabase/server'import { PostsTable } from '@/components/admin/posts-table'export const dynamic = 'force-dynamic'export default async function AdminPosts() {const supabase = await createClient()const { data: posts } = await supabase.from('posts').select(`id,title,status,published_at,author:profiles(display_name)`,).order('created_at', { ascending: false })return (<div><h1 className="text-2xl font-bold mb-6">Manage Posts</h1><PostsTable posts={posts || []} /></div>)}
On-demand revalidation
Webhook handlers should verify a secret, then call revalidatePath and/or revalidateTag. In Next.js 16, revalidateTag accepts an optional second argument: a cacheLife profile (for example 'max') so the framework knows how stale reads may be while fresh data loads [4]. If you need readers to see new content immediately after invalidation, review updateTag in the same docs.
// app/api/revalidate/route.tsimport { NextRequest, NextResponse } from 'next/server'import { revalidatePath, revalidateTag } from 'next/cache'export async function POST(request: NextRequest) {try {const { path, tag } = await request.json()if (path) {revalidatePath(path)}if (tag) {revalidateTag(tag, 'max')}return NextResponse.json({ revalidated: true })} catch {return NextResponse.json({ error: 'Failed to revalidate' }, { status: 500 })}}
Performance Optimization and Best Practices
Database Performance Optimization
Proper database optimization can improve query performance by 10x or more. Always profile your queries in production.
Essential Database Indexes
-- Core content indexes for fast queriesCREATE INDEX idx_posts_status_published_at ON posts(status, published_at)WHERE status = 'published';CREATE INDEX idx_posts_slug ON posts(slug) WHERE status = 'published';CREATE INDEX idx_posts_author ON posts(author_id);CREATE INDEX idx_profiles_role ON profiles(role);-- Full-text search indexesCREATE INDEX idx_posts_title_search ON posts USING GIN(to_tsvector('english', title));CREATE INDEX idx_posts_content_search ON posts USING GIN(to_tsvector('english', content::text));-- Post–category junctionCREATE INDEX idx_post_categories_post ON post_categories(post_id);CREATE INDEX idx_post_categories_category ON post_categories(category_id);
Query Optimization Strategies
// ✅ Optimized query with specific columns and limitsconst { data } = await supabase.from('posts').select(`id,title,slug,excerpt,published_at,author:profiles(display_name, avatar_url)`).eq('status', 'published').order('published_at', { ascending: false }).limit(10)
Advanced Caching Strategy
Multi-Level Caching Architecture
// lib/cache-manager.tsimport { unstable_cache } from 'next/cache'import { createBasicClient } from '@/lib/supabase/server'export const getCachedPosts = unstable_cache(async (limit: number = 10) => {const supabase = createBasicClient()const { data } = await supabase.from('posts').select(`id,title,slug,excerpt,published_at,author:profiles(display_name)`).eq('status', 'published').order('published_at', { ascending: false }).limit(limit)return data},['posts-list'],{revalidate: 3600, // 1 hourtags: ['posts'],})export const getCachedPost = unstable_cache(async (slug: string) => {const supabase = createBasicClient()const { data } = await supabase.from('posts').select('*').eq('slug', slug).eq('status', 'published').single()return data},['post'],{revalidate: 3600,tags: ['posts'],})
Cache Invalidation Strategy
// app/api/revalidate/route.tsimport { NextRequest, NextResponse } from 'next/server'import { revalidatePath, revalidateTag } from 'next/cache'export async function POST(request: NextRequest) {try {const { slug, action } = await request.json()switch (action) {case 'post-updated':revalidateTag('posts', 'max')revalidatePath('/blog')revalidatePath(`/blog/${slug}`)breakcase 'post-deleted':revalidateTag('posts', 'max')revalidatePath('/blog')breakcase 'taxonomy-updated':// Categories or navigation changed — bust list pages and any tagged cachesrevalidateTag('posts', 'max')revalidatePath('/blog')break}return NextResponse.json({revalidated: true,timestamp: Date.now(),})} catch {return NextResponse.json({ error: 'Failed to revalidate' }, { status: 500 })}}
Cache hit rate and response times throughout the day
Content Delivery Optimization
// Development: Real-time updates, no cachingexport const revalidate = 0export const dynamic = 'force-dynamic'export default async function BlogPage() {// Always fetch fresh data for developmentconst posts = await supabase.from('posts').select('*').eq('status', 'published')}
Performance Results
With proper optimization, you can achieve:
- Database queries: Sub-50ms response times
- Page load times: Under 1.5s for cached content
- Cache hit rate: 90%+ during normal traffic
- SEO scores: 95+ Lighthouse performance
Conclusion
You now have a single thread from Next.js 16 (App Router, async params, Turbopack defaults, proxy for Supabase session refresh) through a blog-shaped Postgres schema, RLS, admin UI, Portable Text, and revalidation hooks that match how posts actually ship.
What you covered
Rough weight of topics in this guide
Ship checklist
- Next.js: Server Components for public content,
await params,proxy+@supabase/ssr, metadata from the server only. - Supabase: schema + RLS as the real authorization layer; storage for media;
@supabase/ssrinstead of legacy auth helpers. - Product: Shadcn-backed admin, Portable Text pipeline, ISR +
revalidateTag(andupdateTagwhen you need stronger freshness guarantees).
Before you go to production
- Dependencies: keep
next,react,@supabase/ssr, and@supabase/supabase-json current releases; re-read the upgrade guide when jumping minors [3]. - Supabase: revisit RLS as roles and tables grow; add monitoring on slow queries and storage egress.
- Observability: track Core Web Vitals and cache hit rate alongside Postgres latency.
Natural next steps
Versioned content, realtime co-editing, structured data for SEO, and Supabase Edge Functions for async jobs are all straightforward extensions once the core path—HTML from the server, auth at the proxy, rules in Postgres—is stable.
Keep the architecture honest
The stack stops being “headless CMS cosplay” when crawlers and logged-out readers get the same HTML your components render on the server, and when every mutating path is still impossible without passing RLS. Everything else is polish.