This guide shows you how to wire Clerk authentication into a Next.js 15 app and enforce row-level security (RLS) in a Postgres database so each user can only read and write their own data. Following all steps takes roughly 90 minutes on a fresh project and around 30 minutes if you are adding to an existing codebase.
What You'll Build
- A Next.js 15 app with Clerk sign-in, sign-up, and session management already configured
- A Supabase Postgres database with RLS policies that lock every table to the authenticated user
- A server action that reads and writes user-scoped data without exposing any service-role keys to the client
- A middleware layer that redirects unauthenticated visitors before any page or API route renders
Prerequisites
- Node 20+ and pnpm 9 installed locally
- A free Clerk account
- A free Supabase project (Postgres 15)
- Basic familiarity with Next.js App Router and React Server Components
- A terminal and a code editor
Step 1: Create the Next.js 15 Project
Start from a clean scaffold. The --use-pnpm flag keeps the lockfile consistent across CI.
pnpm create next-app@latest my-app --use-pnpm --typescript --tailwind --app
cd my-app
This gives you Next.js 15 with the App Router, TypeScript, and Tailwind CSS out of the box. Confirm your version before continuing.
pnpm next --version
# Expected: 15.x.x
Common pitfall: Running npx create-next-app without pinning the version can pull Next.js 14. Always check the output before moving on.
Step 2: Install and Configure Clerk
Clerk handles the full auth lifecycle: sign-in, sign-up, session tokens, and user management. It integrates with Next.js 15 via an official SDK tested against App Router and React Server Components.
pnpm add @clerk/nextjs
Where do you get your Clerk API keys?
Log in to the Clerk dashboard, create a new application, and choose your sign-in methods (email/password and Google work well for most SMBs). Copy the two keys from the API Keys screen.
# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
Never commit .env.local to version control. Add it to .gitignore immediately.
Now wrap your root layout with the Clerk provider.
// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs';
import './globals.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
);
}
Step 3: Add Clerk Middleware for Route Protection
Middleware runs on the edge before any page renders. This is where you block unauthenticated requests across the entire app in one place rather than checking auth inside every route.
// middleware.ts (project root)
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
const isPublicRoute = createRouteMatcher(['/sign-in(.*)', '/sign-up(.*)']);
export default clerkMiddleware((auth, req) => {
if (!isPublicRoute(req)) {
auth().protect();
}
});
export const config = {
matcher: ['/((?!_next|.*\\..*).*)', '/'],
};
The matcher pattern skips static files and Next.js internals. Every other route requires a valid Clerk session.
What happens when an unauthenticated user hits a protected route?
Clerk redirects them to your sign-in page automatically. You do not need to write redirect logic yourself. The redirect URL is configurable in the Clerk dashboard under Paths.
Step 4: Create Sign-In and Sign-Up Pages
Clerk provides pre-built components that handle all form state, error messages, and OAuth flows.
// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs';
export default function SignInPage() {
return (
<main className="flex min-h-screen items-center justify-center">
<SignIn />
</main>
);
}
// app/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from '@clerk/nextjs';
export default function SignUpPage() {
return (
<main className="flex min-h-screen items-center justify-center">
<SignUp />
</main>
);
}
The catch-all segment [[...sign-in]] is required. Clerk's multi-step flows use nested routes internally.
Step 5: Set Up Supabase with Row-Level Security
Supabase gives you a managed Postgres 15 database. RLS policies live inside the database itself, so no matter which client or server function queries the database, the rules apply consistently.
Why use RLS instead of filtering in application code?
Application-level filters can be bypassed if a developer forgets to add a WHERE clause. RLS enforces the rule at the database engine level. A user cannot read another user's rows even if your query is missing a filter.
Install the Supabase client and the helper library for JWT verification.
pnpm add @supabase/supabase-js jose
Add your Supabase credentials to .env.local.
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
SUPABASE_JWT_SECRET=your-jwt-secret
The service role key is only used server-side. The JWT secret is found in Supabase under Settings > API.
Step 6: Create the Database Table and RLS Policies
Open the Supabase SQL editor and run the following script.
-- Create the table
create table if not exists notes (
id uuid default gen_random_uuid() primary key,
user_id text not null,
content text not null,
created_at timestamptz default now()
);
-- Enable RLS
alter table notes enable row level security;
-- Policy: users can only select their own rows
create policy "select_own_notes"
on notes for select
using (user_id = current_setting('request.jwt.claims', true)::json->>'sub');
-- Policy: users can only insert their own rows
create policy "insert_own_notes"
on notes for insert
with check (user_id = current_setting('request.jwt.claims', true)::json->>'sub');
-- Policy: users can only delete their own rows
create policy "delete_own_notes"
on notes for delete
using (user_id = current_setting('request.jwt.claims', true)::json->>'sub');
The current_setting('request.jwt.claims') call reads the JWT that your server passes to Supabase on each request. The sub field contains the Clerk user ID.
Step 7: Build a Supabase Client That Passes the Clerk JWT
Supabase needs to receive the Clerk session token so it can evaluate the RLS policies. Create a factory function that builds a scoped client for each request.
// lib/supabase-server.ts
import { createClient } from '@supabase/supabase-js';
import { auth } from '@clerk/nextjs/server';
export async function getSupabaseServerClient() {
const { getToken } = auth();
const token = await getToken({ template: 'supabase' });
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
global: {
headers: {
Authorization: `Bearer ${token}`,
},
},
}
);
}
What is the Clerk JWT template for Supabase?
You need to create a JWT template in your Clerk dashboard. Go to JWT Templates, click New Template, select Supabase, and save. Clerk will sign tokens with the claims that Supabase expects, including the sub field mapped to your Clerk user ID. This step is required. Without it, the RLS policies will receive no user context and deny all queries.
Step 8: Write a Server Action to Read and Write Data
Server actions run exclusively on the server in Next.js 15. No API route needed.
// app/actions/notes.ts
'use server';
import { auth } from '@clerk/nextjs/server';
import { getSupabaseServerClient } from '@/lib/supabase-server';
export async function getNotes() {
const { userId } = auth();
if (!userId) throw new Error('Unauthorised');
const supabase = await getSupabaseServerClient();
const { data, error } = await supabase
.from('notes')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
return data;
}
export async function createNote(content: string) {
const { userId } = auth();
if (!userId) throw new Error('Unauthorised');
const supabase = await getSupabaseServerClient();
const { error } = await supabase
.from('notes')
.insert({ user_id: userId, content });
if (error) throw error;
}
Notice that user_id is always taken from the verified Clerk session on the server. The client never sends a user ID directly.
Step 9: Build a Simple Page to Test the Full Flow
// app/dashboard/page.tsx
import { getNotes, createNote } from '@/app/actions/notes';
import { UserButton } from '@clerk/nextjs';
export default async function DashboardPage() {
const notes = await getNotes();
return (
<main className="max-w-2xl mx-auto p-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">My Notes</h1>
<UserButton afterSignOutUrl="/sign-in" />
</div>
<form
action={async (formData) => {
'use server';
const content = formData.get('content') as string;
if (content) await createNote(content);
}}
className="mb-6"
>
<input name="content" className="border p-2 w-full" placeholder="New note..." />
<button type="submit" className="mt-2 px-4 py-2 bg-black text-white">
Add
</button>
</form>
<ul className="space-y-2">
{notes.map((note) => (
<li key={note.id} className="border p-3 rounded">{note.content}</li>
))}
</ul>
</main>
);
}
Run the dev server and open http://localhost:3000/dashboard. Sign in with two different test accounts and confirm that each user sees only their own notes.
pnpm dev
Step 10: Deploy to Vercel
Vercel is the recommended deployment target for Next.js 15. Push your code to GitHub, connect the repo in the Vercel dashboard, and add your environment variables in the Vercel project settings. All four variables from .env.local need to be added there.
Vercel automatically detects Next.js 15 and sets the build command to pnpm build. Middleware runs on the Vercel Edge Runtime by default, which means authentication checks happen in under 5 ms for users in Australia, Singapore, Canada, and the US thanks to Vercel's global edge network.
Pro tip: Add a preview environment in Vercel that uses separate Clerk and Supabase development projects. This keeps your production data clean during testing.
Frequently Asked Questions
Can I use this setup with a free Supabase project?
Yes. The free Supabase plan includes RLS and JWT authentication. The only limit is the database size cap (500 MB) and connection pooling limits on the free tier. For most early-stage SMB apps, the free tier is sufficient.
What if the JWT template is not showing up in Clerk?
JWT Templates are available on all Clerk plans including the free tier. If you do not see the Supabase template option, make sure your Clerk dashboard is on the latest version by refreshing the page. Templates can sometimes take a minute to appear after account creation.
Does row-level security slow down database queries?
The overhead is minimal. Postgres evaluates RLS policies using the same query planner it uses for WHERE clauses. Benchmarks from the Supabase team show less than 1 ms of added latency per query on indexed tables. Add an index on the user_id column to keep reads fast as your data grows.
How is this different from using Supabase Auth instead of Clerk?
Supabase Auth is a good option for simple projects. Clerk gives you more advanced features out of the box: organisation management, multi-factor authentication, bot protection, and a hosted user management UI. If your app needs team accounts or enterprise SSO, Clerk handles that without additional configuration.
What if my server action returns a permission denied error?
This almost always means the JWT template is not configured in Clerk, or the token is not being passed in the Authorization header. Log the raw token from getToken({ template: 'supabase' }) and decode it at jwt.io to verify the sub claim is present and matches your Clerk user ID.
Next Steps
From here, a few natural extensions will make this production-ready. Add Zod validation inside your server actions to sanitise user input before it reaches the database. Set up Clerk webhooks to sync user creation and deletion events to a users table in Supabase. Add an organisation layer using Clerk's orgId claim if your app needs team-based access control rather than individual user scoping.
If you are building a SaaS product on top of this stack and want a design system to go with it, the guide on building a Shadcn/UI design system for production covers the component layer that fits naturally alongside this backend setup.
At Lenka Studio, we work with SMBs across Australia, Singapore, Canada, and the US who are building apps exactly like this one. If you want a team to scope, design, and ship the full product rather than assembling it piece by piece, get in touch with us and we can walk you through what a realistic build looks like.




