Use this whenever we talk about or write Next.js code — components, API routes, forms, auth, layouts, Supabase integration, etc.
40:T2797,
Standards and patterns for a Next.js App Router application backed by Supabase. All examples use a generic blogging/community platform.
src/
api/ -- Typed RPC wrappers (one file per domain)
app/ -- Next.js App Router
(main-layout)/ -- Route group with shared header/footer layout
auth/callback/ -- OAuth callback route
error.tsx -- Global error boundary
layout.tsx -- Root layout (providers, fonts, toaster)
components/ -- Feature-based folders
layout/ -- Header, Footer, Container
ui/ -- Shared UI (PageError, Toast, Toaster, etc.)
hooks/ -- Custom hooks (useDebounce, useToast, etc.)
lib/
utils.ts -- cn(), formatDate(), getErrorMessage(), getSafeRedirectPath()
supabase/
server.ts -- createSupabaseServerClient()
proxy.ts -- updateSession() for proxy
types.ts -- CustomSupabaseClient type
types/
index.ts -- Shared TypeScript types
styles/
globals.css -- Tailwind theme + global styles
proxy.ts -- Route protection entry point (Next.js 16 renamed middleware to proxy)
Used in server components and API routes:
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createSupabaseServerClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!,
{
cookies: {
getAll() { return cookieStore.getAll() },
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options))
} catch { /* called from Server Component, ignore */ }
},
},
},
)
}
Used in client components. Call createBrowserClient from @supabase/ssr directly — no provider/context needed.
Every domain gets its own file in /src/api/. Each file exports typed Input/Output types + query/mutation functions. All functions take supabase as the first argument and call Supabase RPCs.
// api/posts.ts
export type GetPostInput = { postSlug: string }
export type GetPostOutput = { post: Post }
export async function getPost(
supabase: CustomSupabaseClient,
{ postSlug }: GetPostInput,
): Promise<GetPostOutput> {
const { data, error } = await supabase.rpc('get_post', { p_post_slug: postSlug })
if (error) throw error
if (!data) throw new Error('Post not found')
return { post: data as Post }
}
export type CreatePostInput = { title: string; slug: string; content: string }
export type CreatePostOutput = { postId: string }
export async function createPost(
supabase: CustomSupabaseClient,
input: CreatePostInput,
): Promise<CreatePostOutput> {
const { data, error } = await supabase.rpc('post_create', {
p_title: input.title,
p_slug: input.slug,
p_content: input.content,
})
if (error) throw error
return { postId: data }
}
p_snake_case{ entity: T } or { entities: T[], totalCount: number }{ entityId: string }, updates/deletes return voidif (error) throw error — always throw Supabase errors, let the caller handle them// QUERIES and // MUTATIONS separate read and write operationsAll shared TypeScript types live in /src/types/index.ts. Use type (not interface).
export type Post = {
id: string;
title: string;
slug: string;
content: string;
likes_count: number;
draft: boolean;
created_at: string;
tags: Tag[];
is_liked: boolean;
is_author: boolean;
}
Pages are async server components that fetch data via the API layer.
export default async function HomePage() {
const supabase = await createSupabaseServerClient()
const { posts } = await getPosts(supabase, { sortBy: 'top', maxPosts: 9 })
return <Container>{/* render */}</Container>
}
export default async function PostPage({ params }: { params: { postSlug: string } }) {
const supabase = await createSupabaseServerClient()
try {
const { post } = await getPost(supabase, { postSlug: params.postSlug })
return <Container>{/* render post */}</Container>
} catch (error) {
return <PageError title="Post not found" message="This post doesn't exist." error={error} />
}
}
Two tiers:
<PageError> — inline, user-friendly. For expected failures (not found, no permission).error.tsx — global boundary. Catches unexpected/unhandled errors.In client components, use getErrorMessage() + toast():
try {
await createPost(supabase, input)
toast({ title: 'Success', description: 'Post created', variant: 'success' })
} catch (err) {
toast({ title: 'Error', description: getErrorMessage(err, 'Failed to save post'), variant: 'danger' })
}
'use client'
const formSchema = z.object({
title: z.string().min(1, 'Title is required'),
slug: z.string().min(1, 'Slug is required'),
})
type FormValues = z.infer<typeof formSchema>
export default function PostForm({ postData }: { postData?: Post }) {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { title: postData?.title || '', slug: postData?.slug || '' },
})
async function onSubmit(values: FormValues) {
try {
await createPost(supabase, values)
toast({ title: 'Success', description: 'Saved', variant: 'success' })
} catch (err) {
toast({ title: 'Error', description: getErrorMessage(err, 'Failed'), variant: 'danger' })
}
}
return (
<Form form={form} onSubmit={form.handleSubmit(onSubmit)}>
<Form.Field control={form.control} name="title" render={({ field }) => (
<Form.Item>
<Form.Label>Title</Form.Label>
<Form.Input {...field} />
<Form.Message />
</Form.Item>
)} />
<Button type="submit" disabled={form.formState.isSubmitting}>Save</Button>
</Form>
)
}
formSchema at module level, type FormValues = z.infer<typeof formSchema><Form.Message /> auto-displays validation errorstoast(), not into form field errorsexistingData is providedconst { toast } = useToast()
toast({ title: 'Success', description: 'Post created', variant: 'success' })
toast({ title: 'Error', description: 'Something went wrong', variant: 'danger' })
Variants: 'success' (green), 'danger' (red), 'default' (neutral). <Toaster /> mounted once in root layout.
Important: Always look up the latest official Supabase SSR + Next.js examples before writing or modifying proxy/auth files — this area changes frequently between versions. Copy-paste from the official example and adapt.
Next.js 16 renamed middleware.ts → proxy.ts, exported function middleware → proxy.
// src/proxy.ts
export async function proxy(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}
updateSession in lib/supabase/proxy.ts:
supabase.auth.getClaims() to refresh the session (use getClaims() not getUser() — validates JWT locally, no DB round-trip)// Root layout — providers, fonts, toaster
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<AuthProvider>
<main className="flex min-h-screen flex-col">{children}</main>
<Toaster />
</AuthProvider>
</body>
</html>
)
}
// Route group layout — adds header to most pages
export default function MainLayout({ children }) {
return (
<>
<Header />
<div className="flex-1 flex flex-col">{children}</div>
</>
)
}
Organized by feature, not by type:
components/
layout/ -- Header, Footer, Container (structural)
ui/ -- PageError, Toast, Toaster, Card (shared/generic)
posts/ -- PostForm, PostCard, PostList
comments/ -- Comments, CommentForm
settings/ -- Settings, UpdateProfile
auth/ -- LoginForm, SignupForm
cn() (clsx + tailwind-merge) for conditional classesmt-* between elements (not space-y-* or mb-*)globals.css with @theme inline (Tailwind v4)| Library | Purpose |
|---|---|
@supabase/ssr | Server-side Supabase client (cookies-based auth) |
react-hook-form | Form state management |
@hookform/resolvers + zod | Schema validation |
@radix-ui/react-toast | Toast primitives |
@fortawesome/react-fontawesome | Icons |
clsx + tailwind-merge | Conditional class merging |
date-fns | Date formatting |
// next.config
const nextConfig = {
reactStrictMode: false, // avoids double-rendering in dev
images: { remotePatterns: [{ protocol: 'https', hostname: '**' }] },
eslint: { ignoreDuringBuilds: true },
}
// Disable caching in dev, revalidate every 60s in prod
export const revalidate = process.env.NODE_ENV === 'development' ? 0 : 60