Authentication
ShipVeryFast wires up NextAuth.js with Google OAuth and magic-link email over Mailgun, backed by Supabase. This page covers how the config in libs/auth.ts is assembled, how it degrades gracefully on a fresh clone, and how to read and protect sessions.
NextAuth config in libs/auth.ts
The single source of truth for auth is the authOptions object exported from libs/auth.ts. It uses the JWT session strategy (session: { strategy: "jwt" }), is signed with NEXTAUTH_SECRET, and points NextAuth at the custom auth screens under app/login. The catch-all route handler at app/api/auth/[...nextauth]/route.ts simply passes authOptions to NextAuth() and re-exports it as both GET and POST, so the route handler and getServerSession() always read the same config.
// libs/auth.ts (shape)
export const authOptions: NextAuthOptions = {
...(hasSupabaseBackend
? { adapter: SupabaseAdapter({ url: env.SUPABASE_URL, secret: env.SUPABASE_SERVICE_ROLE_KEY }) }
: {}),
providers, // Google always; Email only with a real backend
session: { strategy: "jwt" },
secret: env.NEXTAUTH_SECRET,
pages: {
signIn: "/login",
verifyRequest: "/login?check=email",
error: "/login",
},
};Google OAuth provider
Google sign-in is always registered. The provider reads GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (both required env vars, validated by the Zod schema in libs/config.ts). Create an OAuth 2.0 Client ID in the Google Cloud Console under APIs & Services → Credentials, then add the NextAuth callback URL as an authorized redirect URI.
# Authorized redirect URIs to register in Google Cloud Console
http://localhost:3000/api/auth/callback/google # local dev
https://your-domain.com/api/auth/callback/google # productionThe path is always /api/auth/callback/google, it is derived from the provider id, so it does not change.
Magic-link email over Mailgun
When a real backend is present, an EmailProvider is added to the front of the providers list. It sends through Mailgun SMTP (smtp.mailgun.org:587, authenticating as postmaster@${MAILGUN_DOMAIN} with MAILGUN_API_KEY) and mails from MAILGUN_FROM_EMAIL. Each send is throttled by the magicLinkRateLimiter, configured via RATE_LIMIT_MAGICLINK_MAX (default 5) and RATE_LIMIT_MAGICLINK_DURATION (default 3600 seconds).
// libs/auth.ts, sendVerificationRequest
sendVerificationRequest: async ({ identifier, url }) => {
try {
await magicLinkRateLimiter.consume(identifier);
} catch {
throw new Error("Too many verification requests, please try again later.");
}
const html = '<p>Sign in with this link: <a href="' + url + '">' + url + '</a></p>';
await sendEmail({ to: identifier, subject: "Your sign-in link", html });
},Graceful degradation without Supabase
NextAuth's EmailProvider hard-requires a database adapter. The placeholder values in .env.example (like your_supabase_service_role_key) do not make a usable adapter, so registering the email provider against them would throw EMAIL_REQUIRES_ADAPTER_ERROR on every /api/auth/session call, surfacing as a CLIENT_FETCH_ERROR in the browser. To avoid this, the config computes hasSupabaseBackend and only wires the adapter and email provider when real credentials are present.
// libs/auth.ts, the backend guard
const hasSupabaseBackend =
!!env.SUPABASE_SERVICE_ROLE_KEY &&
!env.SUPABASE_SERVICE_ROLE_KEY.startsWith("your_") &&
env.SUPABASE_URL.startsWith("https://") &&
!env.SUPABASE_URL.includes("example");When this is false, the app boots adapter-less with Google OAuth and JWT sessions only, so a fresh clone runs cleanly out of the box.
The Supabase adapter path
Once real keys are in place and hasSupabaseBackend is true, the SupabaseAdapter from @next-auth/supabase-adapter is attached using SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY. The adapter persists verification tokens (and user records) to Supabase, which is what makes the magic-link flow work. The service-role key is only ever read on the server, see Database & data models for how supabase and supabaseAdmin differ.
Custom auth pages
All NextAuth redirects route back to a single branded page at app/login. The pagesblock maps three states to it: the sign-in screen, the "check your email" verify-request step, and the error target.
| Key | Target | When |
|---|---|---|
signIn | /login | Unauthenticated user hits a protected route |
verifyRequest | /login?check=email | After a magic link is sent |
error | /login | Sign-in or callback error |
Reading the session
On the client, use the useSession() hook from next-auth/react (the app is wrapped in a session provider in app/providers.tsx). On the server, call getServerSession(authOptions), the repo also ships a thin getAuthSession() helper in app/utils/auth.ts that passes authOptions for you.
// Client component
"use client";
import { useSession } from "next-auth/react";
export function Greeting() {
const { data: session } = useSession();
return <p>Hello {session?.user?.email ?? "guest"}</p>;
}// Server route / page
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/libs/auth";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return Response.json({ error: "Unauthorized" }, { status: 403 });
}
// ...authorized work
}Protecting routes
Route gating happens in middleware.ts, which wraps the pipeline in withAuth from next-auth/middleware. Its authorized callback returns !!token, so any path in the matcher requires a valid JWT. The matcher currently covers /admin/:path*, /app/dashboard/:path*, /app/profile/:path*, and /api/:path*.
/dashboard/:path* matcher entry is commented out in middleware.ts for local preview (browsing without login). Restore it before committing if your dashboard should require auth.For finer-grained admin access, API routes use the email allowlist gate in libs/admin.ts. isAdmin(session) fails closed: if ADMIN_EMAILS (a comma-separated list) is unset or empty, no user is treated as an admin. The security audit and alerts routes pair it with getServerSession.
// app/api/security/audit/route.ts (pattern)
const session = await getServerSession(authOptions);
if (!isAdmin(session)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}Adding another OAuth provider
To add a provider, import it from next-auth/providers/*, push it onto the providers array in libs/auth.ts, add its credentials to the Zod schema in libs/config.ts, and register the callback URL (/api/auth/callback/<id>) with the provider.
// libs/auth.ts
import GitHubProvider from "next-auth/providers/github";
const providers: NextAuthOptions["providers"] = [
GoogleProvider({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET }),
GitHubProvider({ clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET }),
];Next steps
- Environment variables reference, the exact auth keys and their Zod rules
- Database & data models, Supabase clients and the adapter tables
- Rate limiting, the magic-link and auth limiters in detail
