Lesson 48 of 81
Auth in the App Router isn't one piece of code — it's three different jobs, deliberately split across three different places, and putting the wrong job in the wrong place is how a route ends up gated in theory but not in practice.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
"Add auth" actually names three separate jobs, and the App Router has a specific, correct place for each:
proxy.ts (this project's proxy/middleware entry point), because it runs before any page component renders, on every matching request, and it's the one place that can redirect before any server work happens.Mixing these up is the actual bug pattern worth knowing. Putting the redirect check inside a page component instead of middleware means the check only fires if that specific component happens to run it — a sibling page, or a nested layout, can accidentally skip it entirely. This project's own gating makes the point concretely: PROTECTED_PREFIXES in lib/supabase/middleware.ts is the actual source of truth for which routes require auth, not anything inside app/learn/[track]/[lesson]/page.tsx — reading that page's source in isolation shows no redirect() call at all, which would wrongly suggest the route is public.
// lib/supabase/middleware.ts — job 1: route protection, runs before any page renders
const PROTECTED_PREFIXES = ["/learn", "/dashboard", "/interview", "/genealogy", "/settings"];
export async function updateSession(request: NextRequest) {
const supabase = createServerClient(/* ...cookie handling... */);
const { data: { user } } = await supabase.auth.getUser(); // revalidates against Supabase, not just a local cookie read
if (!user && isProtectedPath(request.nextUrl.pathname)) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", request.nextUrl.pathname);
return NextResponse.redirect(url);
}
return supabaseResponse;
}
// app/dashboard/page.tsx — job 2: a Server Component reading who's signed in
import { createClient } from "@/lib/supabase/server";
export default async function DashboardPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
// middleware already guarantees `user` exists here for this route —
// this read is for rendering personalized content, not for gating access
return <h1>Welcome back, {user?.email}</h1>;
}
// app/(auth)/actions.ts — job 3: a Server Action, the actual sign-in mutation
"use server";
export async function login(_prev: AuthActionState, formData: FormData) {
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email: String(formData.get("email")),
password: String(formData.get("password")),
});
if (error) return { error: error.message };
redirect("/dashboard");
}
Notice getUser() appears in both job 1 and job 2, but for different reasons: middleware calls it to decide whether the request may proceed at all; the dashboard page calls it again to read which user, for rendering — the second call isn't redundant paranoia, it's a completely different question being asked in a completely different place. getUser(), not getSession(), matters in both spots — getSession() only reads the (possibly stale) local cookie, while getUser() revalidates the token against Supabase Auth itself.
page.tsx with no visible redirect() doesn't mean the route is public. If gating is centralized in middleware, reading page source alone misses it entirely — the only reliable way to verify a route's real auth status is an actual unauthenticated request (checking for a redirect status code), not a code read.getSession() vs getUser() is not a stylistic choice. getSession() trusts whatever's in the cookie without checking it's still valid; getUser() makes a real round trip to Supabase to confirm the token hasn't been revoked. Using getSession() in a security-relevant check (as opposed to just reading cached user info you already trust) is a real, exploitable gap, not just a slower-vs-faster tradeoff.This builds on the proxy-and-request-interception lesson's general mechanics — what proxy.ts can and can't do, and when it runs relative to routing. Auth gating is simply the single most common reason a real app reaches for that mechanism at all, which is why this project's own middleware file is the concrete example here rather than a synthetic one.