A common question when building with Clerk: “If Clerk handles auth, why do I need Supabase?” The answer is that Clerk and Supabase solve different problems. Clerk manages identity — sessions, OAuth, MFA, and the sign-in UI. Supabase manages your application data — users' projects, settings, subscriptions, and everything else your SaaS stores. You need both, and you need them wired together correctly.
Why use both Clerk and Supabase
Clerk's auth UX is genuinely best-in-class: pre-built sign-in and sign-up components, social login in minutes, passkey support, and an organization system. Supabase gives you a Postgres database with a generous free tier, auto-generated REST and realtime APIs, and Row Level Security baked in. The combination is the fastest path to a secure, production-ready SaaS data layer.
The key is that Clerk owns the user identity (the userId) and Supabase owns everything else. You store Clerk's userId as a foreign key in your Supabase tables, then use Row Level Security policies to ensure each user can only see their own rows.
The three Supabase client types
Supabase provides three different client configurations, and using the wrong one is the most common source of bugs in Next.js + Supabase apps.
Browser client — RLS enforced, for client components
// lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
export const browserClient = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
The browser client uses your public anon key. Row Level Security is fully enforced, so users can only access rows that your RLS policies permit. Use this in "use client"components for reading data that's already scoped by the logged-in user.
Server client — cookie auth, for server components and route handlers
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function serverClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cs) => cs.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
),
},
}
);
}
The server client reads the session from cookies, which is how Next.js server components and route handlers can authenticate requests. RLS is still enforced. Use this when you need to read data in a Server Component or API route without bypassing any security.
Admin client — bypasses RLS, for trusted server-side writes
// lib/supabase/admin.ts
import { createClient } from "@supabase/supabase-js";
export const adminClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
The admin client uses your service role key and bypasses Row Level Security entirely. Never expose this key to the browser — it must only be used in server-side code (API routes, server components, webhook handlers). Use it when you need to write data on behalf of a user, such as creating a new user record during the Clerk sync or updating a subscription from a Stripe webhook.
When to use each client
// Client component reading user's own data → browserClient (RLS enforced)
const { data } = await browserClient.from("projects").select("*");
// Server component or API route reading data → serverClient (RLS enforced via cookies)
const client = await serverClient();
const { data } = await client.from("projects").select("*");
// Webhook handler or privileged write → adminClient (bypasses RLS)
await adminClient.from("subscriptions").upsert({ ... });
The rule of thumb: if you're writing in response to a trusted external event (Stripe webhook, Clerk webhook) or seeding data, use the admin client. For anything driven by a user request, use the browser or server client so RLS stays in the loop.
Syncing Clerk user IDs to Supabase on first login
Clerk manages the session, but your Supabase users table needs to know about each Clerk user so you can join against it. The cleanest pattern is a POST /api/user route that upserts the Clerk user on their first dashboard visit:
// app/api/user/route.ts
import { auth, currentUser } from "@clerk/nextjs/server";
import { adminClient } from "@/lib/supabase/admin";
import { NextResponse } from "next/server";
export async function POST() {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not found" }, { status: 404 });
const { error } = await adminClient.from("users").upsert(
{
clerk_id: userId,
email: user.primaryEmailAddress?.emailAddress ?? "",
},
{ onConflict: "clerk_id" }
);
if (error) {
console.error("[POST /api/user]", error);
return NextResponse.json({ error: "Database error" }, { status: 500 });
}
return NextResponse.json({ success: true });
}
Call this from a client component in your dashboard layout using a useEffect with a ref guard to ensure it fires at most once per session. The upsert means it's safe to call multiple times — if the row already exists, the database no-ops.
Row Level Security policies that reference clerk_id
With Clerk handling the session (not Supabase Auth), you can't use Supabase's built-in auth.uid() function in RLS policies. Instead, pass the Clerk userId as a claim and reference it in your policies, or scope all queries through the admin client where RLS is bypassed in controlled, audited server code.
A minimal RLS policy for the projects table that allows users to see only their own rows:
-- supabase/schema.sql
alter table projects enable row level security;
create policy "Users can read own projects"
on projects for select
using (clerk_id = current_setting('app.clerk_id', true));
create policy "Users can insert own projects"
on projects for insert
with check (clerk_id = current_setting('app.clerk_id', true));
Then set the claim before querying via the server client:
const client = await serverClient();
await client.rpc("set_config", { key: "app.clerk_id", value: userId });
const { data } = await client.from("projects").select("*");
The getUserDbId helper pattern
A common need across route handlers: given a Clerk userId, fetch the corresponding Supabase row ID. Centralizing this into a helper avoids repeating the lookup everywhere and makes it easy to add caching later:
// lib/getUserDbId.ts
import { adminClient } from "@/lib/supabase/admin";
export async function getUserDbId(clerkId: string): Promise<string | null> {
const { data, error } = await adminClient
.from("users")
.select("id")
.eq("clerk_id", clerkId)
.single();
if (error || !data) return null;
return data.id;
}
Use it in any route handler that needs to join against a Supabase user ID:
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const dbUserId = await getUserDbId(userId);
if (!dbUserId) return NextResponse.json({ error: "User not found" }, { status: 404 });
const { data } = await adminClient
.from("projects")
.select("*")
.eq("user_id", dbUserId);
All three Supabase clients, the POST /api/user sync route, RLS policies, and the getUserDbId helper are pre-wired in GetLaunchpad, a Next.js 16 SaaS boilerplate. Get private repo access and skip the configuration work.