Adding authentication to Next.js App Router with Clerk
The complete Clerk setup for Next.js 16 App Router — including the proxy.ts breaking change, protecting routes, reading the user on client and server, and syncing identities to your database.
Adding authentication to a Next.js App Router project involves more than installing a package. You need to protect routes at the middleware level, read the user on both the client and server, and sync identities to your own database. This guide covers the complete Clerk setup for Next.js 16 App Router — including a breaking change that trips up most developers.
Install and configure
npm install @clerk/nextjsAdd your keys to .env.local:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboardWrap the root layout with ClerkProvider
Open app/layout.tsx and wrap your children with <ClerkProvider>. This makes the Clerk session available everywhere in your app — client components, server components, and route handlers.
import { ClerkProvider } from "@clerk/nextjs";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
);
}The breaking change: proxy.ts, not middleware.ts
This is the most common source of confusion for Next.js 16 projects. Next.js 16 renamed the middleware file from middleware.ts to proxy.ts. Every Clerk tutorial and Stack Overflow answer still references middleware.ts. If you follow them, your protected routes will appear to work in development but expose themselves in production — because the middleware is silently ignored.
Create proxy.ts at the root of your project (same level as app/):
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isProtected = createRouteMatcher(["/dashboard(.*)"]);
export default clerkMiddleware((auth, req) => {
if (isProtected(req)) auth().protect();
});
export const config = {
matcher: ["/((?!_next|.*\..*).*)"],
};createRouteMatcher accepts an array of patterns. Add any path that requires a signed-in user. Everything not matched is public by default — your landing page, pricing, and blog stay accessible without a session.
Reading the user in client components
Use useUser() from @clerk/nextjs in any client component. It returns the user object and a loading state:
"use client";
import { useUser } from "@clerk/nextjs";
export function Profile() {
const { user, isLoaded } = useUser();
if (!isLoaded) return <div>Loading...</div>;
if (!user) return null;
return <p>Hello, {user.firstName}!</p>;
}For gating UI behind auth state without the full user object, use useAuth() instead — it is lighter and only returns isSignedIn, userId, and sessionId.
Reading the user in server components and route handlers
On the server, use currentUser() (returns the full user object) or auth() (returns the session claims, faster):
import { currentUser, auth } from "@clerk/nextjs/server";
// In a Server Component:
export default async function DashboardPage() {
const user = await currentUser();
return <h1>Hello, {user?.firstName}</h1>;
}
// In a Route Handler:
export async function GET() {
const { userId } = await auth();
if (!userId) return new Response("Unauthorized", { status: 401 });
// ... handle the request
}Syncing Clerk users to your database
Clerk is the identity layer — it stores email addresses and session tokens. Your own database is where everything else lives: subscription status, user preferences, project data. You need a bridge between the two.
The cleanest pattern is a POST /api/user route that upserts the Clerk user into your database, called once on first dashboard load:
// app/api/user/route.ts
import { auth, currentUser } from "@clerk/nextjs/server";
import { adminClient } from "@/lib/supabase/admin";
export async function POST() {
const { userId } = await auth();
if (!userId) return new Response("Unauthorized", { status: 401 });
const user = await currentUser();
if (!user) return new Response("Not found", { status: 404 });
await adminClient.from("users").upsert({
clerk_id: userId,
email: user.emailAddresses[0]?.emailAddress ?? "",
}, { onConflict: "clerk_id" });
return new Response(null, { status: 200 });
}Call it from the dashboard with a useEffect and a ref guard so it fires at most once per session, not on every render. Using an upsert instead of an insert means the call is safe to make multiple times — if the user already exists, nothing changes.
Sign-in and sign-up pages
Clerk provides pre-built UI components. Create catch-all routes for them:
// app/(auth)/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from "@clerk/nextjs";
export default function Page() {
return <SignIn />;
}
// app/(auth)/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from "@clerk/nextjs";
export default function Page() {
return <SignUp />;
}The [[...slug]]catch-all routes are required because Clerk's hosted UI renders across multiple sub-paths (e.g. /sign-in/factor-one, /sign-in/sso-callback). Without the catch-all, those paths return 404.
Clerk middleware in proxy.ts, the user sync pattern, protected route groups, and sign-in/sign-up pages are all pre-wired in GetLaunchpad, a Next.js 16 SaaS boilerplate. Get private repo access and start building your product today.