Overview
Clerk provides authentication with minimal setup for Next.js apps. Key gotcha: @clerk/nextjs middleware auto-blocks API routes by default — configure matcher carefully or remove if routes don't need auth.
Installation
npm install @clerk/nextjs
// src/app/layout.tsx
import { ClerkProvider } from "@clerk/nextjs";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
);
}
Environment Variables
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
# Optional: customize redirect URLs
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=/dashboard
Middleware Configuration
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isPublicRoute = createRouteMatcher([
"/",
"/sign-in(.*)",
"/sign-up(.*)",
"/api/stripe/webhook", // must be public — Stripe can't authenticate
"/api/health",
]);
export default clerkMiddleware((auth, request) => {
if (!isPublicRoute(request)) {
auth().protect();
}
});
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};
Critical: If an API route does not need auth (public webhooks, health checks), add it to isPublicRoute. Without this, Clerk returns 401 before your handler runs.
Auth in Server Components
import { auth, currentUser } from "@clerk/nextjs/server";
// Just get user ID (faster)
export default async function Dashboard() {
const { userId } = await auth();
if (!userId) redirect("/sign-in");
const data = await fetchUserData(userId);
return <DashboardUI data={data} />;
}
// Get full user object
export default async function Profile() {
const user = await currentUser();
if (!user) redirect("/sign-in");
return (
<div>
<h1>Welcome, {user.firstName}</h1>
<p>{user.emailAddresses[0]?.emailAddress}</p>
</div>
);
}
Auth in API Routes
import { auth } from "@clerk/nextjs/server";
export async function GET() {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Use userId as foreign key in your database
const projects = await db.from("projects").select().eq("user_id", userId);
return NextResponse.json({ projects });
}
Auth in Client Components
"use client";
import { useAuth, useUser } from "@clerk/nextjs";
export function UserMenu() {
const { isLoaded, userId, isSignedIn } = useAuth();
const { user } = useUser();
if (!isLoaded) return <Spinner />;
if (!isSignedIn) return <SignInButton />;
return (
<div>
<span>{user?.firstName}</span>
<UserButton /> {/* Clerk's pre-built avatar dropdown */}
</div>
);
}
Pre-built UI Components
import {
SignIn,
SignUp,
UserButton,
SignInButton,
SignUpButton,
SignedIn,
SignedOut,
} from "@clerk/nextjs";
// Full-page sign-in
// src/app/sign-in/[[...sign-in]]/page.tsx
export default function SignInPage() {
return <SignIn />;
}
// Conditional rendering
function Header() {
return (
<nav>
<SignedOut>
<SignInButton>Login</SignInButton>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</nav>
);
}
Webhook: Sync Users to Database
When a user signs up or updates their profile, Clerk sends a webhook. Sync this to your database:
// src/app/api/clerk/webhook/route.ts
import { Webhook } from "svix";
import { WebhookEvent } from "@clerk/nextjs/server";
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET!;
export async function POST(request: NextRequest) {
const body = await request.text();
const headers = {
"svix-id": request.headers.get("svix-id")!,
"svix-timestamp": request.headers.get("svix-timestamp")!,
"svix-signature": request.headers.get("svix-signature")!,
};
let event: WebhookEvent;
try {
event = new Webhook(WEBHOOK_SECRET).verify(body, headers) as WebhookEvent;
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "user.created":
case "user.updated": {
const { id, email_addresses, first_name, last_name } = event.data;
await db.from("users").upsert({
clerk_id: id,
email: email_addresses[0]?.email_address ?? "",
first_name: first_name ?? "",
last_name: last_name ?? "",
});
break;
}
case "user.deleted": {
await db.from("users").delete().eq("clerk_id", event.data.id);
break;
}
}
return NextResponse.json({ received: true });
}
In Clerk Dashboard → Webhooks, add endpoint https://yourdomain.com/api/clerk/webhook and subscribe to user.created, user.updated, user.deleted.
User Metadata
// Set private metadata (server-side only)
import { clerkClient } from "@clerk/nextjs/server";
await (await clerkClient()).users.updateUserMetadata(userId, {
privateMetadata: {
stripeCustomerId: "cus_xxx",
subscriptionStatus: "active",
},
publicMetadata: {
plan: "pro",
},
});
// Read in server component
const user = await currentUser();
const plan = user?.publicMetadata.plan;
Protecting Pages (Route Groups)
app/
(public)/
page.tsx # marketing page — no auth
(authenticated)/
layout.tsx # checks auth for all children
dashboard/
page.tsx
settings/
page.tsx
// app/(authenticated)/layout.tsx
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
export default async function AuthLayout({ children }) {
const { userId } = await auth();
if (!userId) redirect("/sign-in");
return <>{children}</>;
}