A seven-step tutorial covering Supabase auth settings, production SMTP via Resend, the request form, the callback route, and session refresh middleware.
Methodology. This tutorial follows the Supabase auth docs at supabase.com/docs/guides/auth/auth-magic-link and the Resend SMTP setup at resend.com/docs/send-with-smtp. Code targets Next.js 14 App Router with @supabase/ssr. For broader auth comparisons see best auth library for Next.js.
Magic links are the simplest production auth method that still feels modern: the user types their email, gets a one-time link, clicks it, and is signed in. No password to forget, no OAuth provider relationship, no SMS costs. Supabase ships this out of the box, but a working production setup requires more than flipping a toggle — SMTP, the callback route, and session refresh all have to work together.
From your Supabase project dashboard, go to Authentication → Providers → Email. Confirm two settings:
Then go to Authentication → URL Configuration and set:
https://www.your-domain.com (no trailing slash)http://localhost:3000/auth/callback for dev and https://www.your-domain.com/auth/callback for prodThe redirect URL list is an allowlist. If your callback URL isn’t on it, Supabase rejects the redirect — which manifests as a magic link click that goes nowhere.
Supabase’s built-in email sender is rate-limited and intended for development — quoted as a small number of emails per hour. For production, configure a real SMTP provider. Resend is the easiest in 2026; SendGrid, Postmark, and AWS SES also work.
In the Resend dashboard: create an API key, verify your sending domain (add the SPF, DKIM, and return-path DNS records they show you), then come back to Supabase.
In Supabase, Authentication → SMTP Settings:
SMTP host: smtp.resend.com SMTP port: 465 SMTP user: resend SMTP password: re_xxx_your_resend_api_key Sender email: noreply@your-domain.com Sender name: Your SaaS
Save, then send yourself a test magic link from the Supabase user list. If it arrives in your inbox within seconds, SMTP is wired correctly. If it lands in spam, your DKIM and SPF records likely haven’t propagated yet.
From Authentication → Email Templates → Magic Link, edit the template. The default works, but a branded one improves both deliverability and click-through. Supabase exposes a small set of variables, the most useful being {{ .ConfirmationURL }}.
<h2>Sign in to Your SaaS</h2>
<p>Click the button below to sign in. This link is valid for one hour
and can only be used once.</p>
<p>
<a href="{{ .ConfirmationURL }}"
style="display:inline-block;background:#0f0f0f;color:#fff;
padding:12px 24px;border-radius:8px;text-decoration:none;">
Sign in
</a>
</p>
<p style="color:#666;font-size:13px;">
If you didn’t request this email, you can safely ignore it.
</p>
Install @supabase/ssr and create a browser client. Build a simple form that calls signInWithOtp. Note the emailRedirectTo value — this must match a URL on your allowlist.
npm install @supabase/supabase-js @supabase/ssr
// src/lib/supabase/client.ts
'use client';
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
// src/app/auth/login/page.tsx
'use client';
import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const supabase = createClient();
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
shouldCreateUser: true
}
});
if (error) setError(error.message);
else setSent(true);
}
if (sent) return <p>Check your inbox for a sign-in link.</p>;
return (
<form onSubmit={onSubmit}>
<input type="email" required value={email}
onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Send magic link</button>
{error && <p>{error}</p>}
</form>
);
}
This is the step most often missing. The magic link in the email points at /auth/callback?code=... — that route has to exist and exchange the code for a session. In App Router, this is a route handler.
// src/app/auth/callback/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient } from '@supabase/ssr';
export async function GET(req: Request) {
const url = new URL(req.url);
const code = url.searchParams.get('code');
const next = url.searchParams.get('next') ?? '/dashboard';
if (!code) {
return NextResponse.redirect(new URL('/auth/login?error=missing_code', req.url));
}
const cookieStore = cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get: (name) => cookieStore.get(name)?.value,
set: (name, value, options) => cookieStore.set({ name, value, ...options }),
remove: (name, options) => cookieStore.set({ name, value: '', ...options })
}
}
);
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
return NextResponse.redirect(new URL(`/auth/login?error=${error.message}`, req.url));
}
return NextResponse.redirect(new URL(next, req.url));
}
If this route doesn’t exist, the link click hits a 404 and the user is left stranded. This is a remarkably common bug because the Supabase docs show the form code without always making the callback route obvious in the App Router. For the broader Supabase auth context, see Clerk vs Supabase Auth.
The browser client emits events when the user signs in or out. Wire a listener at the top of your app so you can update UI without a page reload.
// src/app/providers.tsx
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
useEffect(() => {
const supabase = createClient();
const { data: sub } = supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN') router.refresh();
if (event === 'SIGNED_OUT') router.push('/auth/login');
});
return () => sub.subscription.unsubscribe();
}, [router]);
return <>{children}</>;
}
Wrap {children} in your root layout with <AuthProvider>.
Sessions expire. Without refresh, a user signs in, walks away for an hour, comes back and silently has no session. Use Next.js middleware to refresh tokens on every request.
// src/middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { createServerClient } from '@supabase/ssr';
export async function middleware(req: NextRequest) {
const res = NextResponse.next();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get: (name) => req.cookies.get(name)?.value,
set: (name, value, options) => {
res.cookies.set({ name, value, ...options });
},
remove: (name, options) => {
res.cookies.set({ name, value: '', ...options });
}
}
}
);
// refreshes session if expired and rotates the cookie
await supabase.auth.getUser();
return res;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|auth/callback).*)']
};
Sign-out is a simple call:
// src/components/SignOutButton.tsx
'use client';
import { createClient } from '@/lib/supabase/client';
export function SignOutButton() {
return (
<button onClick={async () => {
const supabase = createClient();
await supabase.auth.signOut();
}}>
Sign out
</button>
);
}
The number-one reason magic link auth “doesn’t work” is the developer never configured production SMTP. Supabase’s default sender is throttled to a low rate — fine for testing, broken for any real launch. Resend’s free tier is enough for most early-stage SaaS. Configure SMTP before you launch, not when users start complaining.
If your callback URL isn’t in Authentication → URL Configuration → Redirect URLs, the magic link click silently redirects to your Site URL with no session. Add both your localhost and production callback URLs explicitly.
Pages Router examples don’t translate cleanly to App Router. The route at app/auth/callback/route.ts must exist and call exchangeCodeForSession. If you skipped this, every link click 404s.
If your Site URL is http://localhost:3000 but production deploys at https://www.your-domain.com, the magic link emails sent from production will point at localhost. Update Site URL the same day you deploy.
Without the middleware, sessions don’t refresh server-side, and you get baffling bugs where a user appears signed-in client-side but server components see no user. The middleware in Step 7 is mandatory for App Router.
Magic link auth is the lowest-friction production auth method available. The seven steps above are everything — skip any one of them and your users hit a dead end. Configure SMTP, ship the callback route, and add the middleware. Done.
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.