DEV Community

Gaurav Guha
Gaurav Guha

Posted on Originally published at thefrontkit.com

Add Clerk Authentication to a Next.js App Router Project

The Problem

You need production-ready authentication in a Next.js App Router project without spending weeks building login flows, OAuth providers, 2FA, and session management from scratch.

Step 1: Create the Clerk Application

Sign up at clerk.com and create an application. Pick the providers you want enabled at creation (email, Google, GitHub are the standard set). You can add more later.

Grab two values from the dashboard:

  • NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: safe for the browser
  • CLERK_SECRET_KEY: server-side only

Step 2: Install the SDK

npm install @clerk/nextjs
Enter fullscreen mode Exit fullscreen mode

Add to .env.local:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
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=/onboarding
Enter fullscreen mode Exit fullscreen mode

Step 3: Wrap the App in ClerkProvider

In 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

<ClerkProvider> makes the Clerk client available to every component and handles session state on the server.

Step 4: Add the Middleware

Create middleware.ts at the project root:

import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isProtectedRoute = createRouteMatcher([
  '/dashboard(.*)',
  '/settings(.*)',
  '/api/protected(.*)',
]);

export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) {
    await auth.protect();
  }
});

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Enter fullscreen mode Exit fullscreen mode

The middleware runs on every request. If the route matches the protected list and the user isn't signed in, Clerk redirects to the sign-in page.

Step 5: Use Clerk's Prebuilt Components

The fastest path. Create app/sign-in/[[...sign-in]]/page.tsx:

import { SignIn } from '@clerk/nextjs';

export default function Page() {
  return <SignIn />;
}
Enter fullscreen mode Exit fullscreen mode

And app/sign-up/[[...sign-up]]/page.tsx:

import { SignUp } from '@clerk/nextjs';

export default function Page() {
  return <SignUp />;
}
Enter fullscreen mode Exit fullscreen mode

These render Clerk's polished, accessible auth UI. Customize colors and layout via the appearance prop or a CSS theme.

Step 6: Use Your Template's Auth UI (Custom Flow)

Most templates ship their own login form, signed up to the project's design. The pattern for wiring Clerk into a custom form:

'use client';
import { useSignIn } from '@clerk/nextjs';
import { useRouter } from 'next/navigation';
import { useState } from 'react';

export function CustomSignIn() {
  const { signIn, setActive } = useSignIn();
  const router = useRouter();
  const [error, setError] = useState('');

  async function handleSubmit(formData: FormData) {
    if (!signIn) return;
    try {
      const result = await signIn.create({
        identifier: formData.get('email') as string,
        password: formData.get('password') as string,
      });
      if (result.status === 'complete') {
        await setActive({ session: result.createdSessionId });
        router.push('/dashboard');
      }
    } catch (err: any) {
      setError(err.errors?.[0]?.message || 'Sign in failed');
    }
  }

  return (
    <form action={handleSubmit}>
      <input name="email" type="email" />
      <input name="password" type="password" />
      {error && <p className="text-red-500">{error}</p>}
      <button>Sign in</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

OAuth from a custom button:

'use client';
import { useSignIn } from '@clerk/nextjs';

export function GoogleSignIn() {
  const { signIn } = useSignIn();

  async function handleGoogle() {
    if (!signIn) return;
    await signIn.authenticateWithRedirect({
      strategy: 'oauth_google',
      redirectUrl: '/sso-callback',
      redirectUrlComplete: '/dashboard',
    });
  }

  return <button onClick={handleGoogle}>Continue with Google</button>;
}
Enter fullscreen mode Exit fullscreen mode

Add app/sso-callback/page.tsx:

import { AuthenticateWithRedirectCallback } from '@clerk/nextjs';

export default function Page() {
  return <AuthenticateWithRedirectCallback />;
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Get the User in Server Components

The two patterns:

import { auth, currentUser } from '@clerk/nextjs/server';

// For server components and server actions:
export default async function DashboardPage() {
  const { userId } = await auth();
  if (!userId) return null; // Middleware should have redirected, but defensive

  // Get the full user object only when you need details beyond the ID:
  const user = await currentUser();

  return <div>Hello, {user?.firstName}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Use auth() for the userId; only call currentUser() when you need the full profile.

Step 8: Webhooks for User Sync

When a user signs up, you usually want a row in your own database (for foreign keys to other tables). The pattern is webhooks.

In the Clerk dashboard, set up a webhook endpoint pointing to https://your-app.com/api/webhooks/clerk and subscribe to user.created, user.updated, and user.deleted.

Create app/api/webhooks/clerk/route.ts:

import { headers } from 'next/headers';
import { Webhook } from 'svix';
import { db } from '@/lib/db';

export async function POST(req: Request) {
  const headerPayload = await headers();
  const svix_id = headerPayload.get('svix-id');
  const svix_timestamp = headerPayload.get('svix-timestamp');
  const svix_signature = headerPayload.get('svix-signature');

  if (!svix_id || !svix_timestamp || !svix_signature) {
    return new Response('Missing svix headers', { status: 400 });
  }

  const payload = await req.json();
  const body = JSON.stringify(payload);
  const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);

  let evt: any;
  try {
    evt = wh.verify(body, {
      'svix-id': svix_id,
      'svix-timestamp': svix_timestamp,
      'svix-signature': svix_signature,
    });
  } catch (err) {
    return new Response('Invalid signature', { status: 400 });
  }

  switch (evt.type) {
    case 'user.created':
      await db.user.create({
        data: {
          clerkId: evt.data.id,
          email: evt.data.email_addresses[0]?.email_address,
          firstName: evt.data.first_name,
          lastName: evt.data.last_name,
        },
      });
      break;
    case 'user.updated':
      await db.user.update({ where: { clerkId: evt.data.id }, data: { /* ... */ } });
      break;
    case 'user.deleted':
      await db.user.delete({ where: { clerkId: evt.data.id } });
      break;
  }

  return new Response('OK');
}
Enter fullscreen mode Exit fullscreen mode

Set CLERK_WEBHOOK_SECRET in your environment from the webhook endpoint settings.

Step 9: Organizations and Roles

For B2B SaaS, every user belongs to one or more organizations. Clerk has this built in.

Enable organizations in the Clerk dashboard. Then:

import { auth } from '@clerk/nextjs/server';

export default async function TeamPage() {
  const { orgId, orgRole, orgSlug } = await auth();
  if (!orgId) return <div>Select an organization</div>;
  // orgRole is 'org:admin', 'org:member', or a custom role you define
  return <div>You are {orgRole} in {orgSlug}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Use <OrganizationSwitcher /> for the UI:

import { OrganizationSwitcher } from '@clerk/nextjs';

export function Topbar() {
  return <OrganizationSwitcher />;
}
Enter fullscreen mode Exit fullscreen mode

For role-based access in server components:

const { has } = await auth();
const canManageTeam = has({ role: 'org:admin' });
if (!canManageTeam) {
  return <div>Forbidden</div>;
}
Enter fullscreen mode Exit fullscreen mode

Common Gotchas

Skipping the middleware. Without it, protected routes are accessible to anyone. Always wire the middleware and test the redirect with an incognito tab.

Calling currentUser() when you only need the ID. currentUser() fetches the full profile from Clerk's API. auth() returns the session immediately from the JWT. Use auth() 95 percent of the time.

Forgetting webhook signature verification. Without it, anyone can POST fake events to your webhook and create user records. Always verify with svix.

Not syncing users on signup. If you skip webhooks and try to create user rows lazily on first login, you'll have race conditions and missing foreign keys. Webhook on user.created is the safer pattern.

Hardcoding sign-in URLs. Use the environment variables (NEXT_PUBLIC_CLERK_SIGN_IN_URL etc.) so you can change paths without editing every redirect.

Forgetting OAuth callback page. OAuth needs /sso-callback to handle the redirect. Without it, the OAuth flow finishes nowhere.


This is a shorter version of a guide I wrote at Frontkit Studio. The full post has the complete walkthrough.

Top comments (0)