Auth in Next.js App Router without the waterfall: our middleware-header pattern
Every protected page calling auth.getUser() independently creates a waterfall. We solved it with one middleware that refreshes the Supabase session and injects user identity as request headers — so Server Components read from headers, not from the network.
TL;DR
One middleware call to auth.getUser() per request, forwarded as x-proofly-user-id and friends in request headers. Server Components read from headers via getRequestUser() — zero network calls. Protected route checks happen in middleware too, so layouts never have to redirect. React.cache() deduplicates the header read across the tree.
When we first built Proofly's dashboard with Next.js App Router, every protected page had the same pattern:
// app/dashboard/page.tsx
export default async function DashboardPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/sign-in");
// ... render page
}
It worked. But as we added more pages — testimonials, wall editor, settings, billing — each one made its own auth.getUser() call. That's an HTTP request to Supabase's auth service on every page render, for every user, on every navigation.
On a cold Vercel function invocation that call takes 50–100ms. On a warm function it's 20–40ms. It adds up, and it shows up as latency that has no business being there.
The problem with calling auth.getUser() everywhere#
The App Router's nested layout system means a single page render can produce multiple simultaneous Server Component renders — the root layout, the dashboard layout, and the page itself all run, and if they each call auth.getUser(), that's three network requests to Supabase per navigation.
React's cache() deduplicates calls within a single render pass, so wrapping auth.getUser() in cache() helps:
export const getCurrentUser = cache(async () => {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
return user;
});
But there's a subtler cost: auth.getUser() validates the session token against Supabase's servers. Even with deduplication, you're still making one round-trip per request. And you still have to handle the redirect logic in every layout or page.
The fix: one auth check, forwarded as headers#
Next.js middleware runs before any Server Component renders. It has access to both the request and the response. The pattern we landed on:
- Middleware calls
auth.getUser()exactly once per request to refresh the session - If the user is authenticated, middleware copies their identity into request headers
- Server Components read identity from headers — zero additional network calls
- Protected route redirects happen in middleware — pages and layouts don't need to check
Here's the middleware code:
// lib/supabase/middleware.ts
export const AUTH_USER_ID_HEADER = "x-proofly-user-id";
export const AUTH_USER_EMAIL_HEADER = "x-proofly-user-email";
export const AUTH_USER_NAME_HEADER = "x-proofly-user-name";
export const AUTH_USER_AVATAR_HEADER = "x-proofly-user-avatar-url";
export async function updateSession(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(/* ... cookies config ... */);
// IMPORTANT: nothing between createServerClient and getUser —
// this call refreshes the session cookies.
const { data: { user } } = await supabase.auth.getUser();
const { pathname, search } = request.nextUrl;
const isProtected = PROTECTED_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
);
if (!user && isProtected) {
const url = request.nextUrl.clone();
url.pathname = "/sign-in";
url.searchParams.set("next", `${pathname}${search}`);
return NextResponse.redirect(url);
}
if (user) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set(AUTH_USER_ID_HEADER, user.id);
if (user.email) requestHeaders.set(AUTH_USER_EMAIL_HEADER, user.email);
const name = user.user_metadata?.full_name ?? user.user_metadata?.name ?? null;
if (name) requestHeaders.set(AUTH_USER_NAME_HEADER, encodeURIComponent(name));
const avatarUrl = user.user_metadata?.avatar_url ?? null;
if (avatarUrl) requestHeaders.set(AUTH_USER_AVATAR_HEADER, encodeURIComponent(avatarUrl));
response = NextResponse.next({ request: { headers: requestHeaders } });
}
return response;
}
The key line is NextResponse.next({ request: { headers: requestHeaders } }). This replaces the request headers for all downstream RSCs, not just adds to them. Any header an external caller tried to inject would be overwritten when a real user is present.
We URL-encode the name and avatar URL because HTTP headers don't support arbitrary Unicode. The reader decodes them:
function decodeHeaderValue(value: string | null) {
if (!value) return null;
try { return decodeURIComponent(value); }
catch { return value; }
}
The reader: getRequestUser()#
Server Components get user identity from a single function:
// lib/supabase/user.ts
export const getRequestUser = cache(async () => {
const h = await headers();
const id = h.get(AUTH_USER_ID_HEADER);
if (!id) return null;
return {
id,
email: h.get(AUTH_USER_EMAIL_HEADER) ?? "",
name: decodeHeaderValue(h.get(AUTH_USER_NAME_HEADER)),
avatarUrl: decodeHeaderValue(h.get(AUTH_USER_AVATAR_HEADER)),
};
});
Wrapped in React's cache(), this deduplicates across the entire render tree. The root layout, the dashboard layout, and the page can all call getRequestUser() and they'll share one result — one headers() call, not three.
For protected pages that need to hard-require the user, requireUser() adds a redirect fallback:
export const requireUser = cache(async (redirectTo?: string) => {
const fromHeaders = await getRequestUserId();
if (fromHeaders) return fromHeaders;
// Fallback: the route isn't covered by the middleware matcher,
// or we're in a test environment. Fall back to a real Supabase call.
const user = await getCurrentUser();
if (!user) {
const params = redirectTo ? `?next=${encodeURIComponent(redirectTo)}` : "";
redirect(`/sign-in${params}`);
}
return { id: user.id, email: user.email ?? "" };
});
Fast path: headers (in-process memory). Slow path: auth.getUser() for the edge cases. In production on covered routes, the slow path never fires.
Before and after#
Before (three auth.getUser() calls per dashboard page render):
- Root layout:
auth.getUser()→ ~30ms - Dashboard layout:
auth.getUser()(deduplicated by cache()) → 0ms - Page:
auth.getUser()(deduplicated by cache()) → 0ms - But: three separate
cache()scopes in some configurations → up to 3 × 30ms
After (one auth.getUser() call per request, in middleware):
- Middleware:
auth.getUser()→ ~30ms (runs once regardless of page complexity) - Root layout:
getRequestUser()→ reads from in-process headers → ~0.1ms - Dashboard layout:
getRequestUser()→ deduplicated via cache() → 0ms - Page:
getRequestUser()→ deduplicated via cache() → 0ms
The middleware call happens on the edge anyway — it's unavoidable — so we're not adding latency. We're removing the latency that was happening redundantly in the render tree.
The matcher#
The middleware only runs on routes that need it:
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\..*).*)",
],
};
Static assets, image optimization, and files with extensions are excluded. Everything else — including the marketing pages, auth routes, dashboard, and API routes — goes through middleware. For public API routes like /api/wall/:slug, getUser() returns null and the middleware just passes through without setting headers. Those routes don't read headers for auth; they use the public embed slug instead.
What this looks like in practice#
A dashboard page now looks like:
export default async function DashboardPage() {
const user = await requireUser();
// user.id is available — no redirect logic, no auth.getUser() call
const data = await loadDashboardData(user.id);
return <Dashboard data={data} />;
}
The redirect check happens in middleware before this code even runs. The user identity comes from headers in under a millisecond. The only async work left in the page is the database query for actual data — which is what should be there.
Frequently asked
Quick answers
Isn't reading user identity from headers a security risk?+
Only if untrusted callers can set those headers. In Next.js, the middleware runs in the same process and its output headers are what RSCs read from next/headers — an external request can't inject x-proofly-user-id because middleware always runs first and overwrites whatever was in the incoming request. The key is that we use NextResponse.next({ request: { headers: requestHeaders } }), which replaces the headers, not adds to them.
What about routes not covered by the middleware matcher?+
Static files, _next/static, _next/image, and files with extensions are excluded from the matcher intentionally — they don't need auth. For any route that does fall through without a user header, requireUser() has a fallback that calls auth.getUser() directly. It's slower but correct. In practice it never fires on protected routes.
Why not use the Supabase middleware helper directly instead of building your own?+
We do use @supabase/ssr's createServerClient — the middleware wraps it. What we added on top is the header-forwarding step: after getUser() succeeds, we copy the user id, email, name, and avatar URL into request headers. The Supabase helper doesn't do this by default. Everything else — cookie management, session refresh — comes from @supabase/ssr.