Development18 min readSupabaseNext.js

Supabase with Next.js: The Complete Developer's Guide

Build full-stack apps with Supabase and Next.js using current SSR auth, Postgres, storage, realtime, Edge Functions, MCP, and RLS patterns.

Digital Applied Team
June 11, 2025• Updated October 4, 2026
18 min read

Key Takeaways

Full-Stack Powerhouse:: Combine Postgres, Auth, Storage and Realtime with Next.js; keep permissions and secrets at their own trust boundaries
Developer Experience:: Both tools prioritize DX with TypeScript support, hot reloading, and comprehensive documentation
Production Ready:: Validate RLS, refreshed cookies, callback failures and private response caching before a production launch
2026 Refresh:: October 4 examples cover awaited cookies, verified claims, PKCE callbacks and read-only development MCP; mocked checks do not prove hosted security
Postgres

Relational core

@supabase/ssr

Current SSR auth helper

MCP

AI assistant integration

RLS

Production access control

In the rapidly evolving landscape of web development, the combination of Supabase and Next.js has emerged as a powerhouse solution for building modern, full-stack applications. This comprehensive guide will take you through every aspect of building production-ready applications with these technologies, from database design to realtime features, authentication flows to edge computing.

Ready to Build with Supabase?

Let our team help you leverage the full power of Supabase and Next.js for your next project. From architecture design to implementation, we've got you covered.

Start Your Supabase Project

Why Supabase + Next.js? The Perfect Full-Stack Combination

The combination of Supabase and Next.js represents a paradigm shift in how we build modern web applications. This pairing offers developers the best of both worlds: a powerful, open-source backend-as-a-service with a cutting-edge React framework. If you're still weighing your backend options, see how Supabase stacks up against Firebase before committing.

Supabase Advantages

  • • PostgreSQL data that can be exported and migrated
  • • Built-in email and social authentication
  • • Realtime subscriptions out of the box
  • • Edge Functions for serverless compute
  • • S3-compatible object storage
  • • Generous free tier for startups

Next.js Benefits

  • • Server-side rendering for SEO
  • • API routes for backend logic
  • • Automatic code splitting
  • • Built-in performance optimizations
  • • TypeScript support out of the box
  • • Vercel deployment integration

Setting Up Your Development Environment

Quick Start Guide

1. Create Next.js App with TypeScript

npx create-next-app@latest my-app --typescript --tailwind --app cd my-app

2. Install Supabase Client

npm install @supabase/supabase-js @supabase/ssr

3. Environment Variables

# .env.local (placeholders, never real credentials) NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key APP_ORIGIN=http://localhost:3000 # Keep any secret/service-role key server-only; none is needed here.

Database: Building with PostgreSQL at Scale

Supabase provides a full PostgreSQL database for every project, complete with extensions, realtime functionality, and automatic API generation. Let's explore how to leverage these features in your Next.js application.

Database Architecture

-- Disposable project migration; products are intentionally public.
create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  username text unique not null
);
create table public.products (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  price numeric(10,2) not null check (price >= 0),
  created_at timestamptz not null default now()
);
alter table public.profiles enable row level security;
alter table public.products enable row level security;
grant select on public.products to anon, authenticated;
grant select, insert, update on public.profiles to authenticated;
create policy "public catalog" on public.products
  for select to anon, authenticated using (true);

Key Database Features

Automatic APIs

Instant REST APIs and optional GraphQL support generated from your schema

Database Functions

Write complex business logic directly in PostgreSQL

Triggers & Webhooks

React to database changes with automatic triggers

Extensions

PostGIS, pgvector for AI embeddings, pg_jsonschema, and other supported extensions (availability varies)

Implementing CRUD Operations in Next.js

Server Component Data Fetching

Create the helper below in lib/supabase/server.ts, then import it from the products page using your project alias. The fixture uses relative imports so the examples can be type-checked together. Its cookie-write catch is only for a Server Component with session refresh already handled by Proxy; a Route Handler must write cookies to its response.

// lib/supabase/server.ts (Server Components only; refresh in Proxy) import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; export async function createClient() { const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; if (!(url && key)) { throw new Error("Missing public Supabase configuration"); } const store = await cookies(); return createServerClient(url, key, { cookies: { getAll: () => store.getAll(), setAll(values) { try { for (const { name, value, options } of values) { store.set(name, value, options); } } catch { // Server Components cannot write cookies; Proxy handles refresh. } }, }, }); }
// app/products/page.tsx; adapt the relative import to your file layout. import { createClient } from "./server"; export default async function ProductsPage() { const supabase = await createClient(); const { data, error } = await supabase .from("products") .select("id,name,price") .order("created_at", { ascending: false }) .limit(20); if (error) { throw new Error("Could not load products"); } return ( <ul> {data?.map((p) => ( <li key={p.id}>{p.name}</li> ))} </ul> ); }

Row Level Security (RLS)

Row Level Security is Supabase's killer feature for building secure applications. It allows you to define access rules at the database level.

-- Apply after schema.sql; policy and table grants are both required. create policy "read own profile" on public.profiles for select to authenticated using ((select auth.uid()) = id); create policy "create own profile" on public.profiles for insert to authenticated with check ((select auth.uid()) = id); create policy "update own profile" on public.profiles for update to authenticated using ((select auth.uid()) = id) with check ((select auth.uid()) = id);

Authentication: Complete Security Implementation

Supabase provides a comprehensive authentication solution that integrates seamlessly with Next.js. From social logins to magic links, multi-factor authentication to session management, provider configuration, plan limits and permission tests still determine what is ready for your application.

Authentication Methods Supported

Social Providers

  • • Google
  • • GitHub
  • • Discord
  • • Twitter/X
  • • Other configured OAuth providers

Email Methods

  • • Email/Password
  • • Magic Links
  • • OTP Codes
  • • Email Change
  • • Password Reset

Advanced Features

  • • Phone Auth
  • • MFA/2FA
  • • SAML SSO
  • • Custom JWT
  • • Anonymous Users

Implementing Authentication in Next.js

1. Setting Up Auth Helpers

// lib/supabase/browser.ts import { createBrowserClient } from "@supabase/ssr"; export function createClient() { const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; if (!(url && key)) { throw new Error("Missing public Supabase configuration"); } return createBrowserClient(url, key); }

2. Social Login Implementation

"use client"; import { useState } from "react"; import { createClient } from "./browser"; export function SocialLogin() { const [message, setMessage] = useState(""); async function login() { const supabase = createClient(); const { error } = await supabase.auth.signInWithOAuth({ provider: "google", options: { redirectTo: `${location.origin}/auth/callback` }, }); if (error) { setMessage("Sign-in failed. Please try again."); } } return ( <div> <button onClick={login} type="button"> Sign in with Google </button> <p role="status">{message}</p> </div> ); }

3. Protected Routes with Proxy (Next.js 16)

// proxy.ts on Next.js 16; middleware.ts + middleware on Next.js 15. import { createServerClient } from "@supabase/ssr"; import { type NextRequest, NextResponse } from "next/server"; export async function proxy(request: NextRequest) { const appOrigin = process.env.APP_ORIGIN; if (!appOrigin) { throw new Error("Missing trusted APP_ORIGIN"); } const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; if (!(url && key)) { throw new Error("Missing public Supabase configuration"); } let response = NextResponse.next({ request }); response.headers.set("Cache-Control", "private, no-store"); const supabase = createServerClient(url, key, { cookies: { getAll: () => request.cookies.getAll(), setAll(values, headers) { for (const { name, value } of values) { request.cookies.set(name, value); } const previous = response; response = NextResponse.next({ request }); for (const cookie of previous.cookies.getAll()) { response.cookies.set(cookie); } for (const key of ["cache-control", "expires", "pragma"]) { const value = previous.headers.get(key); if (value) { response.headers.set(key, value); } } for (const { name, value, options } of values) { response.cookies.set(name, value, options); } for (const [key, value] of Object.entries(headers)) { response.headers.set(key, value); } }, }, }); const { data, error } = await supabase.auth.getClaims(); if ( request.nextUrl.pathname.startsWith("/dashboard") && (error || !data?.claims?.sub) ) { const redirect = NextResponse.redirect(new URL("/login", appOrigin)); for (const cookie of response.cookies.getAll()) { redirect.cookies.set(cookie); } for (const key of ["cache-control", "expires", "pragma"]) { const value = response.headers.get(key); if (value) { redirect.headers.set(key, value); } } return redirect; } return response; } export const config = { matcher: ["/dashboard/:path*", "/api/:path*", "/auth/:path*"], };

Storage: File Management Made Simple

Supabase Storage provides an S3-compatible object storage solution that integrates seamlessly with your database. Store, organize, and serve files with automatic image optimization and CDN delivery.

Storage Architecture

Key Features

  • Image transformations (Pro and above)
  • CDN distribution with caching
  • Direct browser uploads
  • RLS policies for file access

Use Cases

  • User profile avatars
  • Product images and galleries
  • Document storage
  • Video file delivery

Implementing File Uploads

Next.js Upload Component

Create a private avatars bucket and an INSERT policy on storage.objects restricted to bucket_id = 'avatars' and the first path folder equal to auth.uid(). Configure MIME and file-size limits in the bucket. This component resets its busy state on success and failure and does not pretend a public URL makes a private object accessible. See Storage access control.

"use client"; import { type ChangeEvent, useState } from "react"; import { createClient } from "./browser"; export function ImageUpload() { const [uploading, setUploading] = useState(false); const [message, setMessage] = useState(""); async function upload(file: File) { setUploading(true); try { const supabase = createClient(); const { data: { user }, error: authError, } = await supabase.auth.getUser(); if (authError || !user) { throw new Error("Sign in first"); } const path = `${user.id}/${crypto.randomUUID()}`; const { error } = await supabase.storage .from("avatars") .upload(path, file, { upsert: false }); if (error) { throw error; } setMessage("Upload saved in your private bucket."); } catch { setMessage("Upload failed. Please try again."); } finally { setUploading(false); } } async function onChange(event: ChangeEvent<HTMLInputElement>) { const file = event.currentTarget.files?.[0]; if (file) { await upload(file); } } return ( <div> <input aria-label="Avatar" disabled={uploading} type="file" onChange={onChange} /> <p role="status">{message}</p> </div> ); }

Image Transformations

Supabase Storage provides on-the-fly image transformations on Pro plans and above. The example requests a 200 × 200 thumbnail at quality 80; these are chosen inputs, not measured speed or quality outcomes. WebP negotiation is automatic, so the earlier unsupported format: "webp" option is removed. See the image transformation reference.

import { createClient } from "./browser"; export function thumbnailUrl() { const supabase = createClient(); const { data } = supabase.storage.from("products").getPublicUrl("image.jpg", { transform: { width: 200, height: 200, resize: "cover", quality: 80 }, }); return data.publicUrl; }

Realtime: Building Live Features

Supabase Realtime enables you to build interactive features like live chat, collaborative editing, and real-time dashboards. It uses PostgreSQL's built-in replication functionality to stream database changes to your application.

Realtime Capabilities

Database Changes

Listen to changes on published tables with suitable permissions

Presence

Track online users and their state in real-time

Broadcast

Send messages between clients without database persistence

Building a Live Chat Feature

1. Database Schema

This miniature schema allows each user to read and insert their own messages. A room filter controls delivery, not permission. A collaborative room needs a separate membership policy and tests; publication alone does not provide access control. See Postgres Changes setup and RLS.

-- Demonstrates a user's own messages, not a shared-room permission model. create table public.messages ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id), room_id uuid not null, content text not null ); alter table public.messages enable row level security; grant select, insert on public.messages to authenticated; create policy "read own messages" on public.messages for select to authenticated using ((select auth.uid()) = user_id); create policy "insert own messages" on public.messages for insert to authenticated with check ((select auth.uid()) = user_id); alter publication supabase_realtime add table public.messages;

2. Realtime Subscription Utility

import { createClient } from "./browser"; export function subscribeToMessages( roomId: string, onInsert: (row: Record<string, unknown>) => void ) { const supabase = createClient(); const channel = supabase .channel(`room:${roomId}`) .on( "postgres_changes", { event: "INSERT", schema: "public", table: "messages", filter: `room_id=eq.${roomId}`, }, (payload) => onInsert(payload.new) ) .subscribe(); return () => { supabase.removeChannel(channel).catch(() => { console.error("Channel cleanup failed"); }); }; }

Presence: Who's Online

Presence metadata is supplied by clients, so a displayed user_id is not proof of identity. Use authorized private channels where access is sensitive, and remove the channel when the view ends. The example demonstrates lifecycle handling, not an authenticated roster.

import { createClient } from "./browser"; export function trackPresence(userId: string) { const supabase = createClient(); const channel = supabase.channel("online-users"); channel .on("presence", { event: "sync" }, () => { console.log(channel.presenceState()); }) .subscribe(async (status) => { if (status === "SUBSCRIBED") { await channel.track({ user_id: userId }); } }); return () => { supabase.removeChannel(channel).catch(() => { console.error("Channel cleanup failed"); }); }; }

Edge Functions: Serverless at the Edge

Supabase Edge Functions are globally distributed TypeScript functions with a Deno runtime. Database location, network requests and cold starts affect latency; choose regions against the actual workload. They support webhooks, authentication, third-party integrations, and complex business logic.

Edge Functions vs Next.js API Routes

Edge Functions

  • • Globally distributed
  • • Deno runtime
  • • Direct database access
  • • Triggered by webhooks/cron
  • • Independent deployment

API Routes

  • • Part of Next.js app
  • • Node.js runtime
  • • Frontend integration
  • • Shared deployment
  • • Vercel optimizations

Creating an Edge Function

Example: Webhook Verification

Correction, October 4: the earlier handler parsed unsigned JSON and could mark an order paid. This replacement verifies the raw body and fails closed, but intentionally performs no payment processing. Its successful response is only a development demonstration. Production needs durable event storage, idempotency, retries and reconciliation before acknowledgement; use our webhook reliability reference and the official Stripe Edge Function example.

// supabase/functions/stripe-webhook/index.ts; verification-only example. import Stripe from "npm:stripe@22.6.2"; declare const Deno: { env: { get(name: string): string | undefined }; serve(handler: (request: Request) => Promise<Response>): unknown; }; const apiKey = Deno.env.get("STRIPE_API_KEY"); const secret = Deno.env.get("STRIPE_WEBHOOK_SECRET"); if (!(apiKey && secret)) { throw new Error("Missing server-only Stripe secrets"); } const stripe = new Stripe(apiKey); const cryptoProvider = Stripe.createSubtleCryptoProvider(); Deno.serve(async (request: Request) => { if (request.method !== "POST") { return new Response("Method not allowed", { status: 405 }); } const signature = request.headers.get("stripe-signature"); if (!signature) { return new Response("Missing signature", { status: 400 }); } const body = await request.text(); try { await stripe.webhooks.constructEventAsync( body, signature, secret, undefined, cryptoProvider ); } catch { return new Response("Invalid signature", { status: 400 }); } // Deliberately no database writes. Add durable idempotent processing before // acknowledging payment events in a production handler. return new Response("Verified sample; no payment processed", { status: 200 }); });

Running the verification sample locally

# Disposable development project only; review the project ref first. supabase functions serve stripe-webhook --no-verify-jwt # Stripe sends its signature, not a Supabase JWT. Keep signature verification. # Production deployment requires an idempotent handler, not this no-op sample.

MCP Servers: AI-Powered Development with Supabase

Model Context Protocol (MCP) servers can give AI assistants controlled access to Supabase project context, making it easier to inspect schemas, draft SQL, manage migrations, and reason about Row Level Security policies during development.

What are MCP Servers?

MCP (Model Context Protocol) servers are standardized interfaces that allow AI assistants to interact with external tools and services. For Supabase developers, this means your AI coding assistant can:

  • Query and modify your database schema directly
  • Generate type-safe database queries and mutations
  • Create and test RLS policies with real-time feedback
  • Deploy edge functions and monitor their performance

Supabase MCP Server Setup

1. Hosted development connection

{ "mcpServers": { "supabase": { "type": "http", "url": "https://mcp.supabase.com/mcp?project_ref=YOUR_DEV_PROJECT_REF&read_only=true" } } }

Correction, October 4: the earlier @supabase/mcp-server install and supabase-mcp init commands were not the documented setup. Use the official hosted MCP endpoint, a scoped development project, read-only access and reviewed tool calls. Read-only mode can still disclose data and does not neutralize prompt injection.

The capability list above describes potential tools, not the permissions of this connection. The supplied read-only configuration cannot perform schema writes, migrations or deployments. VS Code's MCP setup documentation covers its supported client configuration.

2. IDE Integration

Cursor AI

Use the MCP setup supplied by your client and verify the project scope before authorizing the connection

VS Code

VS Code supports configured MCP servers; Continue is not required by the official VS Code setup

Windsurf AI

Check your installed client's supported transport and authentication flow before connecting

Real-World MCP Workflows

Example: AI-Assisted Database Design

Ask the assistant to propose a migration and its RLS tests. Review the SQL, grants, tenant model and generated types in version control. Apply only to a disposable development project after explicit approval. Read-only MCP cannot apply migrations; do not treat generated SQL as tested.

Production Best Practices

Review these security, performance and scaling practices against your application's requirements before production use.

Security Best Practices

  • Always enable RLS on sensitive tables
  • Use service role key only on server-side
  • Implement proper CORS policies
  • Configure and review audit logs with pgaudit
  • Environment-specific API keys

Performance Optimization

  • Index frequently queried columns
  • Choose a suitable Supavisor connection-pool mode
  • Implement query result caching
  • Optimize realtime subscriptions
  • Use CDN for static assets

Use the connection guide to choose direct, session or transaction pooling. Transaction pooling has prepared-statement restrictions; check the mode against your driver. Enable pg_stat_statements before the query below. Its 100 ms threshold and 20-row limit are example settings, not benchmark results.

Monitoring & Observability

Essential Monitoring Setup

1. Database Monitoring
-- Example alert threshold, not a measured latency target. select query, calls, mean_exec_time from pg_stat_statements where mean_exec_time > 100 order by mean_exec_time desc limit 20;
2. API Performance Tracking
// Server-side timing wrapper; send only a fixed route label to your logger. export async function measureProducts<T>( operation: () => Promise<T>, log: (entry: { route: string; durationMs: number; ok: boolean }) => void ) { const start = performance.now(); let ok = false; try { const result = await operation(); ok = true; return result; } finally { log({ route: "/api/products", durationMs: performance.now() - start, ok }); } }

Cost Optimization

Cost Management Strategies

Database Optimization
  • • Use appropriate column types
  • • Archive old data to cold storage
  • • Implement data retention policies
  • • Monitor database size growth
Resource Management
  • • Optimize image sizes before upload
  • • Set appropriate cache headers
  • • Use edge functions judiciously
  • • Monitor bandwidth usage

Building a Complete Application: Task Management System

Use the following owner-only task workspace as a starting point. The feature list is a roadmap: these excerpts do not implement a complete collaborative application, attachments, notifications or optimistic updates.

Application Features

Planned Functionality

  • ✓ User authentication with social logins
  • ✓ Real-time task updates across devices
  • ✓ File attachments with image previews
  • ✓ Team collaboration with presence
  • ✓ Email notifications via edge functions

Technical Checklist

  • ✓ Server-side rendering for SEO
  • ✓ Optimistic UI updates
  • ✓ Row Level Security for data isolation
  • ✓ Responsive design with Tailwind
  • ✓ TypeScript for type safety

Step-by-Step Implementation

Step 1

Database Schema Design

This example is owner-only. Team membership, invitations, roles, billing and collaboration remain application work. Apply migrations to a disposable project and test cross-owner denial before extending permissions.

-- Owner-only workspace example, not team membership or role delegation. create table public.workspaces ( id uuid primary key default gen_random_uuid(), name text not null, owner_id uuid not null references auth.users(id) ); create table public.tasks ( id uuid primary key default gen_random_uuid(), workspace_id uuid not null references public.workspaces(id), title text not null ); alter table public.workspaces enable row level security; alter table public.tasks enable row level security; grant select, insert, update, delete on public.workspaces, public.tasks to authenticated; create policy "owner workspace" on public.workspaces to authenticated using ((select auth.uid()) = owner_id) with check ((select auth.uid()) = owner_id); create policy "owner tasks" on public.tasks to authenticated using (exists (select 1 from public.workspaces w where w.id = workspace_id and w.owner_id = (select auth.uid()))) with check (exists (select 1 from public.workspaces w where w.id = workspace_id and w.owner_id = (select auth.uid()))); alter publication supabase_realtime add table public.tasks;
Step 2

Authentication Flow

Enable the Google provider and register the exact application callback URL in the redirect allowlist. APP_ORIGIN is a trusted deployment setting, not a value taken from the request host. The callback exchanges a PKCE code once, writes cookies to the returned response, and sends missing or rejected codes to a fixed login error route. Email OTP confirmation uses a different flow. See Google OAuth configuration and exchangeCodeForSession.

// app/auth/callback/route.ts (OAuth PKCE callback) import { createServerClient } from "@supabase/ssr"; import { type NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { const appOrigin = process.env.APP_ORIGIN; if (!appOrigin) { throw new Error("Missing trusted APP_ORIGIN"); } const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; if (!(url && key)) { throw new Error("Missing public Supabase configuration"); } const origin = new URL(appOrigin); const fail = () => { const response = NextResponse.redirect( new URL("/login?error=auth", origin) ); response.headers.set("Cache-Control", "private, no-store"); return response; }; const code = new URL(request.url).searchParams.get("code"); if (!code) { return fail(); } const response = NextResponse.redirect(new URL("/dashboard", origin)); response.headers.set("Cache-Control", "private, no-store"); const supabase = createServerClient(url, key, { cookies: { getAll: () => request.cookies.getAll(), setAll(values, headers) { for (const { name, value, options } of values) { request.cookies.set(name, value); response.cookies.set(name, value, options); } for (const [key, value] of Object.entries(headers)) { response.headers.set(key, value); } }, }, }); const { error } = await supabase.auth.exchangeCodeForSession(code); return error ? fail() : response; }
Step 3

Real-time Task Updates

Subscribe only after loading the initial authorized task list. The callback reloads that list after a change; it is not a durable event log. Clean up on component unmount or workspace change and test reconnect reconciliation. RLS does not authorize DELETE notifications. The documentation allows DELETE filtering only with REPLICA IDENTITY FULL, which this schema does not set. Authorized reloads protect the displayed rows, not event-feed privacy; this sample does not provide tenant-private deletion notifications.

import { createClient } from "./browser"; export function subscribeToTasks(workspaceId: string, reload: () => void) { const supabase = createClient(); const channel = supabase .channel(`workspace:${workspaceId}`) .on( "postgres_changes", { event: "*", schema: "public", table: "tasks", filter: `workspace_id=eq.${workspaceId}`, }, () => reload() ) .subscribe(); return () => { supabase.removeChannel(channel).catch(() => { console.error("Channel cleanup failed"); }); }; }

Conclusion & Next Steps

The combination of Supabase and Next.js represents a powerful paradigm for building modern web applications. With Supabase handling your backend infrastructure and Next.js providing a world-class frontend framework, you can focus on building features that matter to your users.

Key Takeaways

What We've Covered

  • ✓ Complete database setup with PostgreSQL
  • ✓ Authentication flows and security
  • ✓ File storage and CDN integration
  • ✓ Real-time features and presence
  • ✓ Edge functions for serverless compute
  • ✓ Production best practices

Next Steps

  • → Implement advanced RLS policies
  • → Explore database functions and triggers
  • → Set up monitoring and alerting
  • → Optimize for scale with caching
  • → Integrate third-party services
  • → Deploy to production

Resources for Continued Learning

Official Documentation

Community & Support

Trust boundaries

October 4, 2026: architecture you can verify

Use this flow to decide which client belongs at each boundary. A publishable key identifies the project; the user's verified token and database policies govern access. Put privileged keys in server-only jobs with separate authorization. They are not a shortcut for browser queries.

  1. Browser → OAuth provider → /auth/callback: exchange the PKCE code and return session cookies.
  2. Browser → Next.js Proxy → Server Component: verify claims, refresh cookies on the request and response, and keep auth responses private.
  3. Server or browser user client → Supabase Data API → Postgres grants + RLS: allow the user's intended rows.
  4. Browser → Storage or Realtime: apply the feature's access policies; an object URL or subscription filter is not authorization.

The code above is checked against this repository's frozen fixture: Next.js 16.3.5, @supabase/ssr 0.12.7, supabase-js 2.116.0, React 19.3.0 and Node 24.21.0. These are tested inputs, not a claim that every version is the newest. The isolated TypeScript files compile against those installed APIs. JavaScript behavior was exercised with mocked cookies, identity responses, storage and channels; the webhook's signature verifier was mocked too. SQL migrations and CLI commands were reviewed but were not executed against a Supabase project. No live login, database mutation or production deployment is claimed.

Next.js documents awaited cookies() and the Proxy file convention. On Next.js 15 use middleware.ts and export middleware instead. Supabase's SSR guide distinguishes getClaims() verification, getUser() for a current user record and getSession() for raw session data. Do not authorize from an unverified getSession() user object.

Proxy refresh is not complete authorization. This sample redirects the dashboard, while each protected Route Handler and Server Action must still verify identity and permissions before acting. Including /api in a refresh matcher does not make its endpoints private.

The Proxy keeps the response that receives refreshed cookies, including cache headers supplied by the installed SSR helper. It copies that state when redirecting. The Server Component helper cannot guarantee browser refresh by itself. Apply Proxy only to routes that use auth, and keep personalized reads and Set-Cookie responses out of shared CDN caches. Use the key-type reference when migrating older anon/service-role configurations.

Checks for a disposable development project before launch
BoundaryTryRequired result
CallbackValid, absent and rejected code; changed request hostOnly valid code reaches the configured dashboard; failures reach the fixed login route.
RefreshExpired token, anonymous request and repeated cookie writesVerified identity or denial, refreshed cookies retained, private cache headers preserved.
DatabaseAnonymous and second-owner select, insert, update and deleteOnly deliberately public products are public; private owner rows cannot cross owners.
StorageWrong folder, wrong bucket, oversized file and upload failurePolicy or bucket limit denies access; UI recovers; private objects need authorized serving.
RealtimeWrong workspace, reconnect, delete and unmountVerify authorized reloads and channel cleanup. Inspect DELETE delivery separately: this sample does not guarantee a tenant-private deletion feed.

Mocked tests can catch missing error branches and lost response cookies. They cannot demonstrate that a hosted policy denies another tenant, a provider redirect is configured correctly, or a CDN respects your cache headers. Run those checks with separate test identities on a disposable project, then inspect deployed response headers. Log outcomes without tokens or personal data. A passing local example is the start of that review, not its completion.

Budget assumptions

Price the complete application

Checked October 4, the Supabase pricing page still lists Free at 500 MB database size, 1 GB file storage and 50,000 monthly active users. Pro starts at USD $25/month and lists 8 GB disk per project, 100 GB file storage and 100,000 monthly active users. Paid plans include USD $10/month compute credits; additional projects and larger compute affect the bill. Free projects pause after one week of inactivity. Add egress, add-ons and the Next.js host to your estimate rather than treating a plan's base price as your total.

If you need MongoDB, this guide does not describe a native replacement for Supabase's Postgres database. Keep MongoDB credentials in a separate server integration, decide which store owns each entity, and design retries or reconciliation for writes across both. For ORM and connection-pool decisions, read the Prisma production guide. For compiler upgrades, use the supabase-js TypeScript migration checklist.

Let's Build Something Amazing Together

Our team specializes in building scalable applications with Supabase and Next.js. From architecture design to implementation, we can help review permissions, callback behavior and deployment assumptions before launch.

Free consultation
Expert guidance
Tailored solutions

Frequently Asked Questions

Digital Applied newsletter

Deep dives on AI, marketing and development.

Practical guides and fresh insights by email. No recycled takes.

Related Guides

Explore more guides on building modern full-stack applications with cutting-edge tools