Clerk handles auth for thousands of Next.js apps. Sign-up, password reset, email verification, magic links - it all works out of the box. But open one of those default emails and you will see a generic white template with Clerk's styling, a tiny logo slot, and copy that reads like it was written for every company at once. Because it was.
For early-stage products, this is fine. Nobody churns because your verification email looks generic. But the moment you start charging money or onboarding enterprise users, those emails become a trust signal. A password reset that looks like a phishing attempt generates support tickets. A welcome email with no brand identity sets the wrong tone for the entire relationship.
Your auth emails are the first and most frequent touchpoint with new users. They set the expectation for everything that follows. A generic template tells users you did not care enough to customize the basics.
Guillermo Rauch
CEO, Vercel
What Clerk gives you by default
Clerk's built-in email system handles six email types through their dashboard editor:
- Email verification (sign-up confirmation)
- Password reset
- Magic link sign-in
- Invitation emails
- Organization invitations
- Two-factor authentication codes
The dashboard lets you edit subject lines, swap in a logo, and tweak copy. But the layout, typography, spacing, and overall design are locked. You cannot add sections, change the email structure, or make it match your product's design system. The WYSIWYG editor is a wrapper around a fixed template, not a design tool.
How the webhook approach works
The architecture is similar to how Supabase's Send Email hook works. You tell Clerk to fire a webhook instead of sending its own email, then your Next.js API route handles the rendering and sending.
Setting up the webhook in Clerk
1. Configure Clerk to use webhooks
In your Clerk dashboard, navigate to Configure → Emails and disable the built-in email sending for the email types you want to customize. Then set up a webhook endpoint under Configure → Webhooks.
# Your Clerk webhook secret (from the Clerk dashboard)
CLERK_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Resend API key for sending
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Your app URL
NEXT_PUBLIC_APP_URL=https://yourapp.com2. Create the webhook API route
Install the Svix package for webhook verification (Clerk uses Svix under the hood):
npm install sviximport { Webhook } from "svix";
import { headers } from "next/headers";
import { Resend } from "resend";
import { renderAsync } from "@react-email/render";
import { WelcomeEmail } from "@/emails/welcome";
import { PasswordResetEmail } from "@/emails/password-reset";
import { VerificationEmail } from "@/emails/verification";
import { MagicLinkEmail } from "@/emails/magic-link";
const resend = new Resend(process.env.RESEND_API_KEY);
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET!;
type EmailType =
| "email.created"
interface ClerkWebhookPayload {
type: string;
data: {
// email.created event data
from_email_name?: string;
to_email_address?: string;
subject?: string;
body?: string;
slug?: string; // "verification_code", "reset_password", etc.
data?: {
otp?: string;
token?: string;
url?: string;
first_name?: string;
};
};
}
export async function POST(req: Request) {
const headerPayload = await headers();
const svixId = headerPayload.get("svix-id");
const svixTimestamp = headerPayload.get("svix-timestamp");
const svixSignature = headerPayload.get("svix-signature");
if (!svixId || !svixTimestamp || !svixSignature) {
return new Response("Missing svix headers", { status: 400 });
}
const payload = await req.json();
const body = JSON.stringify(payload);
const wh = new Webhook(webhookSecret);
let event: ClerkWebhookPayload;
try {
event = wh.verify(body, {
"svix-id": svixId,
"svix-timestamp": svixTimestamp,
"svix-signature": svixSignature,
}) as ClerkWebhookPayload;
} catch {
return new Response("Invalid signature", { status: 400 });
}
await handleEmailEvent(event);
return new Response("OK", { status: 200 });
}
async function handleEmailEvent(event: ClerkWebhookPayload) {
const { data } = event;
const to = data.to_email_address;
if (!to) return;
const emailMap: Record<string, () => Promise<void>> = {
verification_code: async () => {
const html = await renderAsync(
VerificationEmail({
code: data.data?.otp ?? "",
name: data.data?.first_name ?? "",
})
);
await resend.emails.send({
from: "YourApp <auth@yourapp.com>",
to,
subject: "Verify your email address",
html,
});
},
reset_password_code: async () => {
const html = await renderAsync(
PasswordResetEmail({
code: data.data?.otp ?? "",
name: data.data?.first_name ?? "",
})
);
await resend.emails.send({
from: "YourApp <auth@yourapp.com>",
to,
subject: "Reset your password",
html,
});
},
magic_link: async () => {
const html = await renderAsync(
MagicLinkEmail({
url: data.data?.url ?? "",
name: data.data?.first_name ?? "",
})
);
await resend.emails.send({
from: "YourApp <auth@yourapp.com>",
to,
subject: "Sign in to YourApp",
html,
});
},
};
const handler = emailMap[data.slug ?? ""];
if (handler) await handler();
}3. Build your React Email templates
With the webhook handler in place, you need the actual email templates. Here is a verification email that matches your brand instead of Clerk's defaults:
import {
Html,
Head,
Body,
Container,
Section,
Img,
Text,
Hr,
} from "@react-email/components";
interface VerificationEmailProps {
code: string;
name: string;
}
export function VerificationEmail({ code, name }: VerificationEmailProps) {
return (
<Html>
<Head />
<Body style={{ backgroundColor: "#f9fafb", fontFamily: "sans-serif" }}>
<Container
style={{
maxWidth: "480px",
margin: "40px auto",
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px 32px",
}}
>
<Img
src="https://yourapp.com/logo.png"
alt="YourApp"
width={120}
height={36}
/>
<Text style={{ fontSize: "20px", fontWeight: 600, marginTop: "24px" }}>
Verify your email
</Text>
<Text style={{ color: "#6b7280", lineHeight: "1.6" }}>
{name ? `Hey ${name}, enter` : "Enter"} this code to verify your
email address:
</Text>
<Section
style={{
backgroundColor: "#f3f4f6",
borderRadius: "8px",
padding: "20px",
textAlign: "center" as const,
margin: "24px 0",
}}
>
<Text
style={{
fontSize: "32px",
fontWeight: 700,
letterSpacing: "0.15em",
margin: 0,
}}
>
{code}
</Text>
</Section>
<Text style={{ color: "#9ca3af", fontSize: "14px" }}>
This code expires in 10 minutes. If you did not request this,
you can safely ignore this email.
</Text>
<Hr style={{ borderColor: "#e5e7eb", margin: "24px 0" }} />
<Text style={{ color: "#9ca3af", fontSize: "12px" }}>
YourApp Inc. - Sent from auth@yourapp.com
</Text>
</Container>
</Body>
</Html>
);
}Handling every Clerk email type
Clerk fires different webhook events depending on the auth flow. Here is how each one maps to your templates:
- verification_code - sent on sign-up. Render your verification template with the OTP code
- reset_password_code - sent on password reset request. Render your password reset template with the OTP code
- magic_link - sent for passwordless sign-in. Render your magic link template with the sign-in URL
- invitation - sent when inviting a user to your app. Render your invitation template with the accept URL
- organization_invitation - sent when inviting a user to an organization. Include org name and inviter details
Testing the integration
Clerk provides a webhook testing tool in the dashboard, but for local development you will need to expose your local server. Use ngrokor Clerk's built-in tunnel:
# Option 1: ngrok
ngrok http 3000
# Then add the ngrok URL as your webhook endpoint in Clerk:
# https://abc123.ngrok.io/api/clerk-webhooks
# Option 2: Use Clerk's development instance
# Dev instances deliver webhooks to localhost automaticallyFor email preview during development, render your templates directly without the webhook:
import { renderAsync } from "@react-email/render";
import { VerificationEmail } from "@/emails/verification";
export async function GET() {
const html = await renderAsync(
VerificationEmail({
code: "482901",
name: "Alex",
})
);
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
}/api/preview-email in your browser to see the rendered template. Update the props to test different states: long names, missing names, expired codes.Edge cases to handle
Rate limiting
Clerk may fire multiple webhook events in quick succession during sign-up flows (verification + welcome). Your webhook handler should be idempotent - processing the same event twice should not send duplicate emails. Use the svix-id header as a deduplication key.
Webhook failures
If your endpoint returns a non-2xx response, Clerk (via Svix) will retry with exponential backoff. This means a temporary Resend outage will not permanently lose emails. However, you should still log failures and monitor your webhook endpoint health.
Fallback behavior
Consider keeping Clerk's built-in emails enabled as a fallback during initial rollout. If your webhook endpoint goes down, Clerk will still send its default templates. Once you are confident in your webhook reliability, disable the built-in emails to avoid duplicates.
Why custom templates matter for auth emails
Auth emails have a unique constraint: they ask users to take security-sensitive actions. Click this link. Enter this code. Reset this password. Every element of the email either builds or erodes the trust needed for users to follow through.
Generic templates create friction at the worst possible moment. A user who just signed up for your $49/month SaaS product receives a verification email that looks nothing like the product they just saw. The visual disconnect triggers a subconscious question: is this legitimate? That hesitation costs you activations.