
Building a Next.js 15 App with a Supabase Headless CMS
Stewart Moreland
Building a full-stack Next.js application with custom UI components and a headless CMS involves orchestrating several technologies. This comprehensive guide walks through setting up a Next.js 15 project with Shadcn/UI (Tailwind CSS + Radix UI) for a polished interface, using Supabase as a headless CMS (database, storage, and auth), and implementing an admin content editor with rich media support that outputs content in Portable Text format.
What You'll Build
By the end of this guide, you'll have a production-ready Next.js 15 application featuring:
- Modern Shadcn/UI components with Tailwind CSS
- Supabase backend with authentication and role-based access
- Rich admin interface for content management
- Portable Text rendering system
- Optimized performance with SSG and SSR
Critical Next.js 15 Considerations
Important Version Notes
Before starting, be aware of these Next.js 15 considerations that may impact your project:
Next.js 15.1+ has a known issue with metadata streaming that can significantly impact SEO for deployments outside of Vercel. If SEO is critical for your project and you're not deploying to Vercel, consider using Next.js 15.0.x or waiting for a fix.
Ensure you're using Next.js 15.2.3 or later to avoid security vulnerability CVE-2025-29927.
Next.js 15 includes significant improvements to the App Router, but be aware of potential breaking changes when upgrading from earlier versions.
Project Setup: Next.js 15 with Tailwind and Shadcn UI
Let's set up our modern development stack step by step:
Step 1: Initialize Next.js 15 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: Verify Tailwind Configuration
Your tailwind.config.ts should include the Shadcn UI configuration:
import type { Config } from "tailwindcss"const config: Config = {content: ["./pages/**/*.{ts,tsx}","./components/**/*.{ts,tsx}","./app/**/*.{ts,tsx}","./src/**/*.{ts,tsx}",],theme: {extend: {colors: {border: "hsl(var(--border))",input: "hsl(var(--input))",ring: "hsl(var(--ring))",background: "hsl(var(--background))",foreground: "hsl(var(--foreground))",// ... additional CSS variables},},},plugins: [require("tailwindcss-animate")],}export default config
Foundation Complete
With Next.js 15, Tailwind CSS 3.4+, and Shadcn UI in place, you now have a modern, accessible UI foundation that provides a consistent design system and accelerates building common UI patterns.
Setting Up Supabase as a Headless CMS and Auth Backend
Why Choose Supabase?
Supabase is an open-source Firebase alternative built on PostgreSQL, offering a complete backend solution with database, authentication, storage, and real-time subscriptions. As a headless CMS, it provides:
- 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
1. Create a Supabase Project and Connect Next.js 15
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
Configure Supabase Clients for Next.js 15
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 Management
This pattern ensures proper authentication state management across server and client boundaries in Next.js 15, with automatic session refresh and secure cookie handling.
2. Designing the Database Schema (Posts, Portfolio, Pages, Tags, Authors)
Let's design a comprehensive content management schema that leverages PostgreSQL's advanced features:
Core Tables Overview
-- User profiles extending Supabase AuthCREATE 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());-- Blog posts with Portable Text contentCREATE TABLE posts (id UUID DEFAULT gen_random_uuid() PRIMARY KEY,title TEXT NOT NULL,slug TEXT UNIQUE NOT NULL,excerpt TEXT,content JSONB, -- Portable Text structurecover_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());-- Portfolio projectsCREATE TABLE projects (id UUID DEFAULT gen_random_uuid() PRIMARY KEY,title TEXT NOT NULL,slug TEXT UNIQUE NOT NULL,description TEXT,content JSONB, -- Portable Text structureproject_url TEXT,github_url TEXT,technologies TEXT[],featured_image_url TEXT,gallery_urls TEXT[],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());-- Standalone pagesCREATE TABLE pages (id UUID DEFAULT gen_random_uuid() PRIMARY KEY,slug TEXT UNIQUE NOT NULL,title TEXT NOT NULL,content JSONB, -- Portable Text structuremeta_description TEXT,template TEXT DEFAULT 'default',published_at TIMESTAMP WITH TIME ZONE,created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());-- Categories and tagsCREATE 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());-- Junction tables for many-to-many relationshipsCREATE 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));CREATE TABLE project_categories (project_id UUID REFERENCES projects(id) ON DELETE CASCADE,category_id UUID REFERENCES categories(id) ON DELETE CASCADE,PRIMARY KEY (project_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 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_projects_status ON projects(status) WHERE status = 'published';CREATE INDEX idx_profiles_role ON profiles(role);CREATE INDEX idx_posts_author ON posts(author_id);-- GIN index for JSONB content searchCREATE INDEX idx_posts_content_gin ON posts USING GIN(content);CREATE INDEX idx_projects_content_gin ON projects 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();
Modern Database Design
This schema leverages PostgreSQL's advanced features including JSONB for flexible content, UUID primary keys for distributed compatibility, and triggers for automatic data management.
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
-- Enable RLS on all content tablesALTER TABLE posts ENABLE ROW LEVEL SECURITY;ALTER TABLE projects ENABLE ROW LEVEL SECURITY;ALTER TABLE pages ENABLE ROW LEVEL SECURITY;ALTER TABLE categories ENABLE ROW LEVEL SECURITY;-- Public read access to published contentCREATE POLICY "Public posts are viewable by everyone" ON postsFOR SELECT USING (status = 'published');CREATE POLICY "Public projects are viewable by everyone" ON projectsFOR SELECT USING (status = 'published');CREATE POLICY "Public pages are viewable by everyone" ON pagesFOR SELECT USING (published_at IS NOT NULL);-- Admin and editor access to manage contentCREATE POLICY "Admins and editors can manage posts" ON postsUSING (auth.jwt() ->> 'role' = 'authenticated' ANDEXISTS (SELECT 1 FROM profilesWHERE profiles.id = auth.uid()AND profiles.role IN ('admin', 'editor')));CREATE POLICY "Admins and editors can manage projects" ON projectsUSING (auth.jwt() ->> 'role' = 'authenticated' ANDEXISTS (SELECT 1 FROM profilesWHERE profiles.id = auth.uid()AND profiles.role IN ('admin', 'editor')));-- Authors can only edit their own posts (optional)CREATE POLICY "Authors can edit own posts" ON postsFOR UPDATE USING (auth.uid() = author_id ANDEXISTS (SELECT 1 FROM profilesWHERE profiles.id = auth.uid()AND profiles.role IN ('admin', 'editor')));-- Profile access policiesCREATE 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 can manage all profiles" ON profilesUSING (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.
Next.js 15 Middleware for Route Protection
import { createServerClient } from '@supabase/ssr'import { NextResponse, type NextRequest } from 'next/server'export async function middleware(request: NextRequest) {let supabaseResponse = NextResponse.next({request,})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, options }) =>request.cookies.set(name, value),)supabaseResponse = NextResponse.next({request,})cookiesToSet.forEach(({ name, value, options }) =>supabaseResponse.cookies.set(name, value, options),)},},},)// Refresh session if expiredconst {data: { user },} = await supabase.auth.getUser()// Protect admin routesif (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)$).*)',],}
Enhanced Security Features
This middleware implementation includes:
- Redirect preservation: Stores the original URL for post-login redirection
- Role-based API protection: Secures admin API endpoints
- Header context: Passes user information to server components
- Performance optimization: Efficient pattern matching for static assets
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 Admin Interface and Content Authoring Tools
The admin panel is a protected section where authorized users can manage content. We'll build this using Next.js 15 server components, Shadcn UI components, and modern React patterns.
Admin Route Protection with Next.js 15
Create an admin layout at /app/admin/layout.tsx that handles authentication and role checking:
// app/admin/layout.tsximport { createServerClient } 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 createServerClient()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>)}
Supporting Static Generation and Server-Side Rendering in Next.js 15
Next.js 15 brings enhanced caching and rendering capabilities. Here's how to leverage them effectively:
Static Site Generation (SSG) with Enhanced Caching
// app/blog/[slug]/page.tsximport { createServerClient } from '@/lib/supabase/server'import { PortableTextRenderer } from '@/components/portable-text-renderer'import { notFound } from 'next/navigation'export async function generateStaticParams() {const supabase = await createServerClient()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: { slug: string }}) {const supabase = await createServerClient()const { data: post } = await supabase.from('posts').select('title, excerpt, meta_description').eq('slug', params.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: { slug: string }}) {const supabase = await createServerClient()const { data: post } = await supabase.from('posts').select(`title,content,published_at,author:profiles(display_name, avatar_url)`,).eq('slug', params.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>)}// Enable ISR with 1 hour revalidationexport const revalidate = 3600
Server-Side Rendering for Dynamic Content
// app/admin/posts/page.tsximport { createServerClient } from '@/lib/supabase/server'import { PostsTable } from '@/components/admin/posts-table'// Force dynamic rendering for admin pagesexport const dynamic = 'force-dynamic'export default async function AdminPosts() {const supabase = await createServerClient()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
Implement webhook-based revalidation for content updates:
// app/api/revalidate/route.tsimport { NextRequest, NextResponse } from 'next/server'import { revalidateTag, revalidatePath } from 'next/cache'export async function POST(request: NextRequest) {try {const { path, tag } = await request.json()if (path) {revalidatePath(path)}if (tag) {revalidateTag(tag)}return NextResponse.json({ revalidated: true })} catch (error) {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));-- Category and tag relationshipsCREATE 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'export const getCachedPosts = unstable_cache(async (limit: number = 10) => {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 { 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 { revalidateTag, revalidatePath } from 'next/cache'export async function POST(request: NextRequest) {try {const { type, slug, action } = await request.json()switch (action) {case 'post-updated':revalidateTag('posts')revalidatePath('/blog')revalidatePath(`/blog/${slug}`)breakcase 'post-deleted':revalidateTag('posts')revalidatePath('/blog')breakcase 'project-updated':revalidateTag('projects')revalidatePath('/projects')revalidatePath(`/projects/${slug}`)break}return NextResponse.json({revalidated: true,timestamp: Date.now()})} catch (error) {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
This comprehensive guide demonstrates how to build a production-ready Next.js 15 application with Supabase as a powerful headless CMS. You now have the knowledge to create a scalable, secure, and performant content management system.
What You've Accomplished
Skills and technologies covered in this guide
Key Technical Achievements
Next.js 15 Modernization
- App Router mastery with server/client component patterns
- Enhanced metadata handling with SEO optimization
- Advanced middleware for route protection and auth
- Performance optimization with ISR and caching strategies
Supabase Integration Excellence
- PostgreSQL expertise with advanced schema design
- Row Level Security implementation for fine-grained access control
- Real-time capabilities with database subscriptions
- Storage management for rich media content
Production-Ready Features
- Role-based access control with secure admin interfaces
- Portable Text implementation for flexible content management
- Performance monitoring with optimized queries and caching
- Type-safe development with full TypeScript integration
Architecture Benefits Summary
Development Best Practices Covered
- ✅ Row Level Security (RLS) policies
- ✅ JWT-based authentication
- ✅ Role-based access control
- ✅ Input validation and sanitization
- ✅ Secure API endpoint protection
Critical Reminders for Production
Monitor the metadata streaming issue in Next.js 15.1+ if SEO is critical for your deployment. Consider using 15.0.x or alternative metadata strategies for non-Vercel deployments.
Regularly update Supabase client libraries, review RLS policies as your application grows, and implement comprehensive error boundaries for production resilience.
Set up monitoring for database query performance, cache hit rates, and Core Web Vitals to maintain optimal user experience as your content grows.
Next Steps and Advanced Features
Recommended Enhancements
Consider implementing these advanced features as your application grows:
- Real-time collaboration for multi-author content editing
- Advanced analytics with user behavior tracking
- Content versioning and revision history
- Automated backup strategies for data protection
- Edge computing with Supabase Edge Functions
- Advanced SEO features including structured data and sitemap generation
Final Thoughts
Building with Next.js 15 and Supabase provides unmatched flexibility and control over your content management system. This stack offers:
- Developer productivity through modern tooling and patterns
- Scalability to handle growing content and user bases
- Performance that meets modern web standards
- Security that protects your content and users
- Flexibility to evolve with changing requirements
The knowledge gained from this guide positions you to build sophisticated, production-ready applications that can compete with any modern CMS while maintaining complete control over your data and user experience.
Ready for Production
You now have a solid foundation to build, deploy, and scale a modern content management system. Start building, iterate based on user feedback, and enjoy the flexibility this powerful stack provides!