Connect Supabase to a Next.js Template (2026)
Connect Supabase to a Next.js Template in 2026
Supabase has become the default "Postgres + Auth + Storage + Realtime" stack for Next.js apps. It's open source, the free tier is generous, and the API is genuinely good. The integration with a Next.js template is mostly straightforward, but there are gotchas around server components, middleware, and row-level security that bite teams who skip them.
This guide walks the full wiring: install the SDK, configure environment variables, set up auth callbacks, query the database from server components, upload files to Storage, and the security patterns you should not skip.
Save the setup time entirely: get every kit for $499
The SaaS Starter Kit ships with auth screens, dashboards, and settings already wired into pluggable auth and database layers. Drop Supabase in and most of the integration work is done. All Access unlocks every kit on thefrontkit for $499 one-time.
Why Supabase + Next.js Is the Default Stack in 2026
Supabase gives you Postgres (the database every serious SaaS uses), auth (with OAuth providers, magic links, and 2FA), storage (S3-compatible file uploads), realtime (WebSocket-based pub/sub), and edge functions (for server-side logic close to users). All of it is exposed through a clean TypeScript SDK that works in both Server Components and Client Components.
The alternatives:
- Firebase — Google's equivalent. Real-time is solid but the database (Firestore) is NoSQL, which limits SQL-style querying.
- PlanetScale + Clerk + S3 — three separate services, more flexible, more setup.
- Convex — newer reactive database. Great DX but smaller ecosystem.
- Self-hosted Postgres + Auth.js + S3 — maximum control, more ops work.
Supabase wins for most teams because the integrated stack means you write less glue code.
Step 1: Create the Supabase Project
Sign up at supabase.com and create a new project. Pick a region close to your users — latency matters for database calls. Free tier gives you 500 MB database, 1 GB file storage, 2 GB egress, and 50,000 monthly active auth users. Plenty for a starting product.
Once the project is provisioned, grab three values from the API settings:
SUPABASE_URL— the project's API endpointSUPABASE_ANON_KEY— the public client-side key (safe to ship)SUPABASE_SERVICE_ROLE_KEY— the server-side admin key (never expose to the browser)
Step 2: Install the SDK and Configure Environment
npm install @supabase/supabase-js @supabase/ssr
Add to .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...your-anon-key
SUPABASE_SERVICE_ROLE_KEY=eyJ...your-service-role-key
NEXT_PUBLIC_* prefix exposes the variable to the browser. The service role key has no prefix because it must stay server-side.
Step 3: Create the Supabase Clients
Next.js 16 with the App Router needs three slightly different clients: a server client, a browser client, and a middleware client. The patterns:
lib/supabase/server.ts:
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
},
},
}
);
}
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!
);
}
middleware.ts at the project root:
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
}
);
await supabase.auth.getUser();
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
The middleware refreshes the auth session on every request. Without it, server components see stale sessions.
Step 4: Wire Auth Into the Template
Most Next.js templates ship with auth screens (login, signup, forgot password). Replace the auth handlers with Supabase calls.
Login handler:
'use server';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
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');
}
Signup handler:
export async function signup(formData: FormData) {
const supabase = await createClient();
const { error } = await supabase.auth.signUp({
email: formData.get('email') as string,
password: formData.get('password') as string,
options: { emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback` },
});
if (error) return { error: error.message };
redirect('/auth/check-email');
}
OAuth (Google example):
export async function signInWithGoogle() {
const supabase = await createClient();
const { data } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback` },
});
if (data.url) redirect(data.url);
}
Callback route at app/auth/callback/route.ts:
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
if (code) {
const supabase = await createClient();
await supabase.auth.exchangeCodeForSession(code);
}
return NextResponse.redirect(`${origin}/dashboard`);
}
Enable OAuth providers in the Supabase dashboard under Authentication > Providers.
Step 5: Query the Database from Server Components
The single biggest reason to use Supabase is server-side database queries that feel like local function calls.
import { createClient } from '@/lib/supabase/server';
export default async function ProjectsPage() {
const supabase = await createClient();
const { data: projects, error } = await supabase
.from('projects')
.select('*, tasks(count)')
.order('created_at', { ascending: false });
if (error) throw new Error(error.message);
return (
<div>
{projects.map((p) => (
<div key={p.id}>{p.name}</div>
))}
</div>
);
}
Server components run on the server, so the database call happens inside the Vercel function, never the browser. No API route needed for read-only views.
Step 6: Enable Row-Level Security (RLS)
This is the step most teams skip. It will bite you.
Without RLS, your anon key gives the browser unrestricted access to every table. Anyone can query any user's data. The fix is row-level security policies.
For a projects table where each project belongs to a user:
-- Enable RLS on the table
alter table projects enable row level security;
-- Allow users to select only their own projects
create policy "Users can view their own projects"
on projects for select
using ( auth.uid() = user_id );
-- Allow users to insert projects with their own user_id
create policy "Users can insert their own projects"
on projects for insert
with check ( auth.uid() = user_id );
-- Allow users to update their own projects
create policy "Users can update their own projects"
on projects for update
using ( auth.uid() = user_id );
-- Allow users to delete their own projects
create policy "Users can delete their own projects"
on projects for delete
using ( auth.uid() = user_id );
Run this in the Supabase SQL editor for every table that contains user-specific data. Test by hitting the API with the anon key from a logged-out browser tab — every query should return empty.
Step 7: File Uploads via Supabase Storage
Create a storage bucket in the Supabase dashboard. Set it to "public" or "private" depending on whether you want anonymous read access. For user-uploaded files (avatars, attachments), pick private.
Upload a file from a client component:
'use client';
import { createClient } from '@/lib/supabase/client';
async function uploadAvatar(file: File, userId: string) {
const supabase = createClient();
const fileName = `${userId}/${Date.now()}-${file.name}`;
const { data, error } = await supabase.storage
.from('avatars')
.upload(fileName, file, { upsert: true });
if (error) throw error;
return data.path;
}
Get a signed URL for displaying a private file:
const { data } = await supabase.storage
.from('avatars')
.createSignedUrl(filePath, 3600); // 1 hour expiry
Set storage policies the same way as table policies — every bucket should have RLS that scopes access to the owning user.
Step 8: Realtime Subscriptions (Optional)
For features that need live updates — a kanban board where two people are editing, a chat, a presence indicator — Supabase realtime is the fastest path.
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
export function LiveTasks({ projectId }: { projectId: string }) {
const [tasks, setTasks] = useState([]);
const supabase = createClient();
useEffect(() => {
const channel = supabase
.channel(`tasks:${projectId}`)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'tasks', filter: `project_id=eq.${projectId}` },
(payload) => {
// Handle insert, update, delete
console.log(payload);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [projectId]);
return <div>...</div>;
}
Realtime requires enabling replication on the table in the Supabase dashboard. Without that, the subscription succeeds but no events fire.
Common Gotchas
Forgetting RLS. The biggest security risk in any Supabase project. Default-deny by enabling RLS, then add specific allow policies. Never ship with RLS off.
Server vs browser client confusion. The server client uses cookies for session; the browser client uses the in-memory session. Mixing them up causes "user is null" bugs that look like auth is broken when it isn't.
Skipping the middleware. Without the auth-refreshing middleware, server components see stale or expired sessions. Symptoms: random logouts, "user is null" after navigation.
Service role key in client code. The service role key bypasses RLS. It must only run on the server. Putting it in a NEXT_PUBLIC_ variable or referencing it from a client component is a critical security incident.
Forgetting emailRedirectTo for signups. Without it, Supabase redirects to its own confirmation page, which doesn't carry the session into your app. Always specify the redirect.
Real-time without replication enabled. Subscribing to changes on a table that doesn't have replication on means silent failure. Enable replication in the dashboard for every table you subscribe to.
Free tier limits. 500 MB database, 1 GB storage, 2 GB egress. Easy to hit if you store user-uploaded files in Storage and don't add CDN caching. Plan for the Pro tier ($25/month) once you have meaningful users.
Adjacent Reads
- How to Build a SaaS in Next.js — the broader build path
- Best Next.js SaaS Starter Kits 2026 — kit comparison
- SaaS Starter Kit vs Building From Scratch — build-vs-buy
FAQ
Why use Supabase over Firebase for Next.js? Postgres. The relational model with foreign keys, joins, and SQL beats Firestore's document model for any application with structured data (which is most applications). Supabase auth is comparable to Firebase Auth. Storage is comparable to Firebase Storage. The difference comes down to: do you want SQL or document queries? Most SaaS apps want SQL.
Do I need to use Supabase auth, or can I pair Supabase database with Clerk?
You can mix and match. Use Clerk for auth, Supabase for database and storage. The pattern: Clerk owns user identity, you sync the Clerk userId to your Supabase tables via webhooks, and your RLS policies use the Clerk userId instead of auth.uid(). Some teams prefer this because Clerk's UI is more polished than Supabase Auth's built-in flows.
How do I migrate from another database to Supabase? For Postgres: pg_dump the source, pg_restore into Supabase. For MySQL: use a migration tool like pgloader. For Firebase: write a one-time script that reads from Firestore and inserts into Supabase tables. Always test the migration on a staging instance first. Be aware that Supabase auth users are a separate identity layer; migrating user accounts requires the admin API.
Is Supabase production-ready for a serious SaaS? Yes. It's used by companies like Mozilla, GitHub Next, and PwC. The Pro tier ($25/month) gives you the SLA. For very large scale (millions of MAUs or hundreds of GB database), the Team tier or self-hosted Supabase makes sense. Free tier is fine for early-stage products learning what they are.
Can I use Supabase with Next.js Server Actions?
Yes. Server actions are just async functions that run on the server. Call await createClient() inside the action and use it the same way you would in a server component. The session and auth state work the same way.
What's the performance cost of RLS?
Negligible for simple policies (auth.uid() = user_id). For complex policies with joins, RLS can add latency. The fix: keep policies simple, denormalize when necessary, and use indexes on the columns used in policy expressions. For 95 percent of applications, RLS is free.
