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

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

Stewart Moreland

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

middlewareproxy. 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

  • metadata in Client Components. The metadata export and generateMetadata only 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-js with @supabase/ssr and 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

Create Next.js project
npx create-next-app@latest my-app --tailwind --typescript --app
cd my-app
💡 Why These Flags?

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.

Initialize Shadcn UI
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

bash
npx shadcn@latest add button input card dialog sheet
npx 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:

tailwind.config.ts (Tailwind v3-style sketch)
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
💡 Do not cargo-cult Tailwind v3 configs on v4

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.

Setting Up Supabase as your blog backend

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

  1. Sign up at supabase.com and create a new project
  2. Navigate to SettingsAPI to find your credentials
  3. Copy your Project URL and Anon Public Key

Configure Environment Variables

.env.local
NEXT_PUBLIC_SUPABASE_URL=your_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
💡 Security Note

Never commit your .env.local file to version control. Add it to your .gitignore file.

Install Supabase dependencies

Install Supabase packages
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)

lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
)
}

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

Database schema SQL (blog-focused)
-- 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 body
CREATE 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 format
cover_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 ↔ categories
CREATE 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

💡 UUID Primary Keys

UUIDs provide better distributed systems compatibility and prevent enumeration attacks compared to sequential integers.

💡 JSONB for Content

JSONB columns allow flexible, searchable content structure perfect for Portable Text while maintaining query performance.

💡 PostgreSQL Enums

CHECK constraints for status fields ensure data integrity at the database level.

Performance Optimizations

Database Indexes
-- Indexes for common blog queries
CREATE 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

Database Triggers
-- Function to handle new user creation
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT 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 creation
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-- Function to update timestamps
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply to all tables with updated_at
CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON profiles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_posts_updated_at
BEFORE UPDATE ON posts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

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

RLS policies for a public blog + private admin
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 only
CREATE POLICY "Public posts are viewable by everyone" ON posts
FOR SELECT USING (status = 'published');
-- Staff: full CRUD on posts (drafts, scheduling, etc.)
CREATE POLICY "Staff manage posts" ON posts
FOR ALL
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role IN ('admin', 'editor')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role IN ('admin', 'editor')
)
);
-- Taxonomy: public read, staff write
CREATE POLICY "Categories are readable" ON categories
FOR SELECT USING (true);
CREATE POLICY "Staff manage categories" ON categories
FOR ALL
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role IN ('admin', 'editor')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role IN ('admin', 'editor')
)
);
-- Junction: public only sees links for published posts; staff sees all
CREATE POLICY "Post categories for published posts" ON post_categories
FOR SELECT USING (
EXISTS (
SELECT 1 FROM posts
WHERE posts.id = post_categories.post_id
AND posts.status = 'published'
)
);
CREATE POLICY "Staff manage post categories" ON post_categories
FOR ALL
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role IN ('admin', 'editor')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role IN ('admin', 'editor')
)
);
CREATE POLICY "Users can view own profile" ON profiles
FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own profile" ON profiles
FOR UPDATE USING (auth.uid() = id);
CREATE POLICY "Admins manage profiles" ON profiles
FOR ALL
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role = 'admin'
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role = 'admin'
)
);

Role-based Access Control Helper Functions

Database Helper Functions
-- Helper function to check if user has specific role
CREATE OR REPLACE FUNCTION user_has_role(required_role TEXT)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role = required_role
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Helper function to check if user is admin or editor
CREATE OR REPLACE FUNCTION user_can_edit()
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role IN ('admin', 'editor')
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Helper function to get user role
CREATE OR REPLACE FUNCTION get_user_role()
RETURNS TEXT AS $$
SELECT role FROM profiles WHERE id = auth.uid();
$$ LANGUAGE sql SECURITY DEFINER;
💡 RLS Best Practices

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.

proxy.ts — enhanced route protection
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 caching
const { 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 components
supabaseResponse.headers.set('x-user-role', profile.role)
supabaseResponse.headers.set('x-user-id', user.id)
}
// Protect API routes
if (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)$).*)',
],
}

4. Supabase Storage for Media

Supabase Storage provides S3-compatible object storage. Create buckets for different media types:

Storage Buckets
-- Create storage buckets
INSERT INTO storage.buckets (id, name, public) VALUES
('uploads', 'uploads', true),
('avatars', 'avatars', true);
-- Set up storage policies
CREATE POLICY "Public Access" ON storage.objects FOR SELECT USING (bucket_id = 'uploads');
CREATE POLICY "Authenticated users can upload" ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'uploads' AND auth.role() = 'authenticated');

Modern file upload pattern with React:

File Uploader
// components/admin/file-uploader.tsx
import { 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) return
const 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 uploadError
const {
data: { publicUrl },
} = supabase.storage.from('uploads').getPublicUrl(filePath)
onUpload(publicUrl)
} catch (error) {
console.error('Error uploading file:', error)
} finally {
setUploading(false)
}
}
return (
<input
type="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:

Admin Layout
// app/admin/layout.tsx
import { 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:

Post Form
// 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 structure
status: 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 submission
console.log(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Enter post title..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input placeholder="post-slug" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<PortableTextEditor
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={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:

Portable Text Editor
// 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: string
type: BlockType
content: 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) return
const newIndex = direction === 'up' ? index - 1 : index + 1
if (newIndex < 0 || newIndex >= value.length) return
const 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">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveBlock(block.id, 'up')}
disabled={index === 0}
>
<MoveUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveBlock(block.id, 'down')}
disabled={index === value.length - 1}
>
<MoveDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => deleteBlock(block.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<BlockEditor
block={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: Block
onChange: (content: any) => void
}) {
switch (block.type) {
case 'paragraph':
return (
<Textarea
value={block.content.text || ''}
onChange={e => onChange({ text: e.target.value })}
placeholder="Enter paragraph text..."
rows={4}
/>
)
case 'heading':
return (
<div className="space-y-2">
<Select
value={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>
<Input
value={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">
<Input
value={block.content.url || ''}
onChange={e => onChange({ ...block.content, url: e.target.value })}
placeholder="Image URL..."
/>
<Input
value={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:

Portable Text Renderer
// components/portable-text-renderer.tsx
import { 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">
<Image
src={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">
<SyntaxHighlighter
language={value.language || 'javascript'}
style={oneDark}
customStyle={{
borderRadius: '0.5rem',
padding: '1rem',
}}
>
{value.code}
</SyntaxHighlighter>
</div>
),
video: ({ value }) => (
<div className="my-6">
<iframe
src={value.url}
width="100%"
height="400"
frameBorder="0"
allowFullScreen
className="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.

SSG blog post
// app/blog/[slug]/page.tsx
import { 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 params
const 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 params
const 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 hour
export const revalidate = 3600
💡 createBasicClient vs createClient

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

Admin posts page
// app/admin/posts/page.tsx
import { 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.

Revalidation route
// app/api/revalidate/route.ts
import { 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

💡 Database Performance Impact

Proper database optimization can improve query performance by 10x or more. Always profile your queries in production.

Essential Database Indexes

Performance Indexes
-- Core content indexes for fast queries
CREATE 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 indexes
CREATE 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 junction
CREATE INDEX idx_post_categories_post ON post_categories(post_id);
CREATE INDEX idx_post_categories_category ON post_categories(category_id);

Query Optimization Strategies

typescript
// ✅ Optimized query with specific columns and limits
const { 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)
📊 Database Query Performance
-75%
15msms
Query Time
-80%
2.1KBKB
Data Transfer
-60%
12MBMB
Memory Usage

Advanced Caching Strategy

Multi-Level Caching Architecture

Caching Implementation
// lib/cache-manager.ts
import { 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 hour
tags: ['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

API route for cache invalidation
// app/api/revalidate/route.ts
import { 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}`)
break
case 'post-deleted':
revalidateTag('posts', 'max')
revalidatePath('/blog')
break
case 'taxonomy-updated':
// Categories or navigation changed — bust list pages and any tagged caches
revalidateTag('posts', 'max')
revalidatePath('/blog')
break
}
return NextResponse.json({
revalidated: true,
timestamp: Date.now(),
})
} catch {
return NextResponse.json({ error: 'Failed to revalidate' }, { status: 500 })
}
}
Cache Performance Over Time

Cache hit rate and response times throughout the day

Content Delivery Optimization

typescript
// Development: Real-time updates, no caching
export const revalidate = 0
export const dynamic = 'force-dynamic'
export default async function BlogPage() {
// Always fetch fresh data for development
const posts = await supabase
.from('posts')
.select('*')
.eq('status', 'published')
}

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

Stack emphasis

Rough weight of topics in this guide

📊 Targets worth measuring
-60%
1.2ss
Page load (cached)
-75%
25msms
DB queries
+25%
96/100
Lighthouse
+30%
94%%
Cache hit rate

Before you go to production

  • Dependencies: keep next, react, @supabase/ssr, and @supabase/supabase-js on 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.