Next.js SaaS Application Patterns
Patterns and architecture for building production-grade, multi-tenant SaaS applications using Next.js 16+, React 19, App Router, Supabase auth and modern tooling.
When to Use This Skill
-
Starting a new Next.js SaaS project
-
Adding multi-tenancy to an existing Next.js app
-
Implementing the proxy pattern for auth (API vs UI routes)
-
Setting up authentication with Supabase
-
Integrating Shadcn/ui component library
-
Implementing Context-based state management
-
Structuring App Router projects for scale
Quick Reference
Topic Reference File
Directory layout, route groups references/project-structure.md
Proxy pattern, middleware auth references/proxy-pattern.md
Server/Client components, Shadcn references/component-patterns.md
Context, hooks, localStorage references/state-management.md
Tailwind v4, CSS Modules, theming references/styling.md
Supabase auth, server actions references/authentication.md
RLS, tenant isolation, data scoping references/multi-tenancy.md
App config, env vars, TypeScript references/configuration.md
Recommended Stack
Framework: Next.js 16+ (App Router, Turbopack) Language: TypeScript (strict mode) Runtime: React 19 Styling: Tailwind CSS v4 + CSS Modules Components: Shadcn/ui + Radix primitives Database: Supabase (Postgres + Auth + RLS) State: React Context + localStorage
Core Principles
- Default to Server Components
Only add 'use client' when you need interactivity (useState, onClick, browser APIs).
- No Inline Styles - CSS Modules Required
Never use style={{ }} props. Every component with custom styling must have a companion .module.css file. Use data-* attributes for state-dependent styles.
- Context Over Redux
For most SaaS apps, React Context + localStorage is sufficient. Only add Zustand/Redux for complex cross-tree state.
- Proxy Pattern for Auth
Separate API route authentication (return 401 JSON) from UI route authentication (redirect to login). Never redirect API consumers.
- Route Groups for Layouts
Use (dashboard) , (marketing) , (auth) to share layouts without affecting URLs.
- RLS for Multi-Tenancy
Supabase Row-Level Security ensures data isolation at the database level - never rely on frontend filtering alone.
- CSS Variables for Theming
Enable white-label/customization by using CSS custom properties that can be updated at runtime.
Project Structure Overview
├── app/ │ ├── (dashboard)/ # Protected routes with shared layout │ ├── (marketing)/ # Public marketing pages │ ├── auth/ # OAuth callbacks │ ├── login/ # Auth pages + server actions │ └── api/ # API routes ├── components/ │ ├── ui/ # Shadcn primitives │ ├── shared/ # Cross-feature components │ └── [feature]/ # Feature-specific ├── contexts/ # React Context providers ├── hooks/ # Custom hooks ├── lib/ # Utilities (cn, supabase client) ├── utils/ │ └── supabase/ │ ├── client.ts # Browser client │ ├── server.ts # Server client │ ├── middleware.ts # Session refresh for UI routes │ └── apiAuth.ts # API route auth handler ├── types/ # TypeScript definitions ├── config/ # App configuration └── proxy.ts # Auth routing layer
See references/project-structure.md for detailed layout and route group patterns.
The Proxy Pattern
The proxy pattern separates authentication handling for API routes vs UI routes:
// proxy.ts import { type NextRequest, NextResponse } from 'next/server' import { updateSession } from '@/utils/supabase/middleware' import { handleApiAuth } from '@/utils/supabase/apiAuth'
const PUBLIC_API_ROUTES = [ '/api/health', '/api/auth/callback', '/api/webhooks/stripe', ]
export async function proxy(request: NextRequest) { const pathname = request.nextUrl.pathname
// API routes: return 401 JSON, never redirect if (pathname.startsWith('/api/')) { if (PUBLIC_API_ROUTES.some(route => pathname.startsWith(route))) { return NextResponse.next() } return handleApiAuth(request) }
// UI routes: may redirect to login return await updateSession(request) }
export const config = { matcher: [ '/((?!_next/static|_next/image|favicon.ico|.\.(?:svg|png|jpg|jpeg|gif|webp)$).)', ], }
Key insight: API consumers (mobile apps, webhooks, external services) expect 401 responses, not redirects. UI routes can redirect to login.
See references/proxy-pattern.md for full implementation details.
Key Patterns at a Glance
Server Component (Data Fetching)
// app/(dashboard)/projects/page.tsx import { createClient } from '@/utils/supabase/server'
export default async function ProjectsPage() { const supabase = await createClient() const { data } = await supabase.from('projects').select() return <ProjectsList projects={data} /> }
Client Component (Interactivity)
// components/shared/theme-toggle.tsx 'use client' import { useState } from 'react'
export function ThemeToggle() { const [dark, setDark] = useState(false) return <button onClick={() => setDark(!dark)}>Toggle</button> }
Context Provider
// contexts/tenant-context.tsx 'use client' const TenantContext = createContext<TenantContextType | undefined>(undefined)
export function TenantProvider({ children }) { const [tenantId, setTenantId] = useState<string | null>(null) return ( <TenantContext.Provider value={{ tenantId, setTenantId }}> {children} </TenantContext.Provider> ) }
export const useTenant = () => useContext(TenantContext)
Server Action (Mutations)
// app/login/actions.ts 'use server' import { redirect } from 'next/navigation' import { createClient } from '@/utils/supabase/server'
export async function login(formData: FormData) { const supabase = await createClient() const { error } = await supabase.auth.signInWithPassword({ email: formData.get('email') as string, password: formData.get('password') as string }) if (error) return { error: error.message } redirect('/dashboard') }
API Route Auth Handler
// utils/supabase/apiAuth.ts export async function handleApiAuth(request: NextRequest) { const response = NextResponse.next()
const supabase = createServerClient(/* ... */) const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
// Inject user info for downstream handlers response.headers.set('X-User-Id', user.id) response.headers.set('X-User-Email', user.email || '')
return response }
References
Detailed documentation for each topic area:
-
references/project-structure.md
-
Directory conventions, route groups, file organization
-
references/proxy-pattern.md
-
Auth routing, API vs UI handling, public routes
-
references/component-patterns.md
-
Server vs Client components, Shadcn setup, composition
-
references/state-management.md
-
Context patterns, custom hooks, persistence
-
references/styling.md
-
Tailwind v4 config, CSS Modules, dynamic theming
-
references/authentication.md
-
Supabase auth flow, server actions, OAuth
-
references/multi-tenancy.md
-
RLS policies, tenant isolation, data scoping
-
references/configuration.md
-
App config, environment variables, TypeScript setup