Supabase is the go-to open-source Firebase alternative for Next.js projects. With @supabase/ssr, you get cookie-based authentication that works across Server Components, Server Actions, Route Handlers, and middleware — all reading from the same session without any client-server mismatch.
>Setup
bash
npm install @supabase/supabase-js @supabase/ssrbash
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...>Server client — Server Components and Actions
TypeScript
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import type { Database } from '@/types/supabase';
export async function createClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
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),
);
},
},
},
);
}
// Usage in a Server Component
export default async function Dashboard() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect('/login');
const { data: posts } = await supabase
.from('posts')
.select('*')
.eq('user_id', user.id);
return <PostsList posts={posts ?? []} />;
}>Middleware — refresh sessions
TypeScript
// proxy.ts (Next.js 16)
import { createServerClient } from '@supabase/ssr';
import { NextRequest, NextResponse } from 'next/server';
export async function proxy(req: NextRequest) {
let res = NextResponse.next({ request: req });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => req.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
req.cookies.set(name, value);
res.cookies.set(name, value, options);
});
},
},
},
);
// Refreshes the session if expired — must be called in middleware
const { data: { user } } = await supabase.auth.getUser();
// Protect routes
if (!user && req.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', req.url));
}
return res;
}
export const config = { matcher: ['/((?!_next/static|favicon.ico).*)'] };>Row Level Security policies
SQL
-- Enable RLS on the posts table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Users can only read their own posts
CREATE POLICY "select_own_posts" ON posts
FOR SELECT USING (auth.uid() = user_id);
-- Users can only insert posts for themselves
CREATE POLICY "insert_own_posts" ON posts
FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Users can update and delete their own posts
CREATE POLICY "modify_own_posts" ON posts
FOR ALL USING (auth.uid() = user_id);
-- Allow public read on published posts
CREATE POLICY "select_published" ON posts
FOR SELECT USING (published = true);NOTEWith RLS enabled, queries from the Supabase client automatically filter by the authenticated user. The service_role key bypasses RLS — never expose it client-side.
>Realtime subscriptions
TypeScript
'use client';
import { createBrowserClient } from '@supabase/ssr';
import { useEffect, useState } from 'react';
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
export function LiveNotifications({ userId }: { userId: string }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
const channel = supabase
.channel('notifications')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${userId}`,
},
(payload) => {
setNotifications((prev) => [payload.new as Notification, ...prev]);
},
)
.subscribe();
return () => { supabase.removeChannel(channel); };
}, [userId]);
return (
<ul>{notifications.map((n) => <li key={n.id}>{n.message}</li>)}</ul>
);
}>Storage — file uploads
TypeScript
'use server';
import { createClient } from '@/lib/supabase/server';
export async function uploadAvatar(formData: FormData) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Unauthenticated');
const file = formData.get('avatar') as File;
const ext = file.name.split('.').pop();
const path = `avatars/${user.id}.${ext}`;
const { error } = await supabase.storage
.from('public-assets')
.upload(path, file, { upsert: true });
if (error) throw error;
const { data: { publicUrl } } = supabase.storage
.from('public-assets')
.getPublicUrl(path);
await supabase.from('profiles')
.update({ avatar_url: publicUrl })
.eq('id', user.id);
}