Auth.js + Resend: the 30-minute magic link auth setup
Authentication is the first thing founders try to build themselves and the first thing they should not.
I have lost count of the number of MVPs I've reviewed where the founder, asked about auth, says some version of "oh yeah I just did the basic email/password thing, I hash the passwords with bcrypt, it's fine." And then I look at the code and find that the session token is a 4-digit number stored in localStorage, password reset emails are sent in plain text, the "remember me" checkbox does nothing, and admin access is gated by checking if the email contains the string "admin."
This is the default. Don't do the default.
The shape that works for an MVP is: Auth.js v5 with the Resend (email magic link) provider, plus optionally Google OAuth. It takes 30 minutes to set up. The user types their email, gets a link, clicks the link, they're in. No passwords, no password reset, no "I forgot my password" support emails for you to handle.
Here's the whole flow.
What you'll need
- A Next.js 15 app (App Router).
- A Resend account (resend.com, free tier covers 100 emails/day).
- A database adapter — for MVP, the in-memory JWT strategy is fine. You'll need a DB later for sessions to persist across devices, but skip that on day one.
Step by step
1. Get a Resend API key. Sign up. Go to API Keys. Create one. Copy the value (starts with re_).
2. Install:
npm install next-auth@beta
3. Add to .env.local:
AUTH_SECRET=<openssl rand -base64 32>
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
EMAIL_FROM="App <onboarding@resend.dev>"
The onboarding@resend.dev sender works without verifying your domain. Production should switch to your own domain after you set up DNS records (see Resend's docs), but onboarding@resend.dev is fine for testing.
4. Create auth.ts at the project root:
import NextAuth from 'next-auth';
import Resend from 'next-auth/providers/resend';
import Google from 'next-auth/providers/google';
const providers: any[] = [];
if (process.env.RESEND_API_KEY) {
providers.push(Resend({ from: process.env.EMAIL_FROM }));
}
if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) {
providers.push(Google);
}
export const { handlers, auth, signIn, signOut } = NextAuth({
providers,
session: { strategy: 'jwt' },
pages: { signIn: '/login' },
});
The conditional providers pattern means you can add or remove providers by setting/unsetting env vars — no code changes needed.
5. Wire the auth route handler at app/api/auth/[...nextauth]/route.ts:
export { GET, POST } from '@/auth';
That's it. Two lines.
6. Build the login page at app/login/page.tsx:
import { signIn } from '@/auth';
export default function LoginPage() {
return (
<div className="max-w-md mx-auto px-6 py-20">
<h1 className="text-3xl font-semibold">Sign in</h1>
<p className="mt-2 opacity-75">Email yourself a one-tap link.</p>
<form
action={async (formData: FormData) => {
'use server';
await signIn('resend', { email: formData.get('email') });
}}
className="mt-8 flex flex-col gap-2"
>
<input
name="email"
type="email"
required
className="input input-bordered"
placeholder="you@example.com"
/>
<button className="btn btn-primary">Send magic link</button>
</form>
</div>
);
}
The user types their email, hits "Send magic link," and Auth.js + Resend take it from there. They get an email with a link. They click. They're signed in.
7. Gate a protected route by calling auth() server-side at the top:
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) redirect('/login');
return <div>Welcome, {session.user.email}</div>;
}
8. Sign-out button:
import { signOut } from '@/auth';
<form action={async () => { 'use server'; await signOut(); }}>
<button className="btn btn-ghost btn-sm">Sign out</button>
</form>
Done. End-to-end auth in eight steps. About 30 minutes the first time.
Why this beats the alternatives
vs. rolling your own. Auth.js handles session token generation, cookie security flags (httpOnly, Secure, SameSite=Lax), CSRF protection, OAuth state parameters, and the dozens of other things that go wrong when you build it yourself. It is maintained by people who care about this stuff full-time. You are not.
vs. Clerk. Clerk is great. It costs money beyond the free tier (which is real but not generous). For an MVP with under 10,000 monthly actives, Auth.js is free. The trade-off is that Auth.js makes you wire the UI yourself; Clerk gives you pre-built sign-in components. If your time is more valuable than $25/month, Clerk wins. Otherwise Auth.js does.
vs. Supabase Auth. Also great. Tighter coupling to Supabase the database. Use it if you're already using Supabase for everything; otherwise Auth.js is more portable.
vs. Firebase Auth. It's 2026, please don't.
What about email/password?
If your users specifically expect email/password (legacy industries, older audiences), you can add the Auth.js Credentials provider on top. You'll need to hash passwords with bcryptjs, store them in a users table, and add a verification flow. This adds maybe an hour to the build. The skill bundle's auth skill (sub-skill 03) walks through it.
For most modern audiences, magic link is the better UX. Less friction at signup, no "I forgot my password" support flow, no password breach risk.
The single most-skipped step
Before launch, use the OWASP-recommended secure cookie flags. Auth.js sets them by default — don't override them. Check your browser dev tools after signing in and confirm the session cookie shows HttpOnly, Secure, SameSite=Lax. If it doesn't, you've broken something.
That's the whole thing. Auth.js + Resend, 30 minutes, real auth, no passwords. Don't write your own. Use this.