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
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
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>
);
}
<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).*)'],
};
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 />;
}
And app/sign-up/[[...sign-up]]/page.tsx:
import { SignUp } from '@clerk/nextjs';
export default function Page() {
return <SignUp />;
}
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>
);
}
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>;
}
Add app/sso-callback/page.tsx:
import { AuthenticateWithRedirectCallback } from '@clerk/nextjs';
export default function Page() {
return <AuthenticateWithRedirectCallback />;
}
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>;
}
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');
}
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>;
}
Use <OrganizationSwitcher /> for the UI:
import { OrganizationSwitcher } from '@clerk/nextjs';
export function Topbar() {
return <OrganizationSwitcher />;
}
For role-based access in server components:
const { has } = await auth();
const canManageTeam = has({ role: 'org:admin' });
if (!canManageTeam) {
return <div>Forbidden</div>;
}
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)