D
Dev SOPKnowledge Base
Search
← All topics

Clerk Authentication: Setup, Middleware, Webhooks, and User Management

Clerk authentication integration for Next.js — installation, middleware configuration, auth in Server Components and API routes, user metadata, webhook sync to database, and production gotchas including auto-blocking behavior.

clerkauthenticationnextjsmiddlewarewebhooksusers
Agent trigger phrases: Clerk · Clerk auth · Clerk middleware · Clerk Next.js · Clerk webhook · Clerk user · ClerkProvider · auth() Clerk · currentUser Clerk

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}</>;
}