Wiring Up the Frontend: Next.js App Router, NextAuth, and a Rewrite Proxy That Saved Me
After the NestJS backend was running, the frontend needed to connect to it. That sounds simple. It wasn’t.
App Router and two route groups
RhizoBook has two distinct surfaces: a public marketing page and the actual app (dashboard, appointments, provider search). I chose App Router over Pages Router because nested layouts are the right model for this — different surfaces, different layout shells, shared routing behavior.
Two route groups handle this cleanly:
(marketing)/— the public landing page. No navigation bar, no session check.(app)/— everything else. Shares a layout with<Navigation>. This covers both authenticated routes (dashboard, appointments) and unauthenticated-but-app routes (provider search, provider detail). Unauthenticated access to(app)/routes isn’t blocked by middleware — individual pages handle it by checkinguseSession()and redirecting if needed.
The distinction means the marketing page never pulls in the navigation component or its dependencies. The app layout is always present once you’re past the landing page.
NextAuth JWT strategy
Auth is NextAuth v4 with CredentialsProvider. The credentials handler in lib/auth.ts calls the NestJS backend (BACKEND_URL) directly, server-to-server, and exchanges email/password for a JWT. That backend JWT is then stored in the NextAuth session token along with roleId and roleName.
The jwt callback enriches the token on first sign-in:
async jwt({ token, user }) {
if (user) {
token.accessToken = user.accessToken;
token.roleId = user.roleId;
token.roleName = user.roleName;
}
return token;
}
The session callback exposes those fields on the client-side session object. Client components read them via useSession(). Server-side code (route handlers, server components) uses getServerSession(authOptions) from lib/auth.ts.
ADR 002: Replace NEXT_PUBLIC_API_URL with a rewrite proxy
Early on, the frontend used NEXT_PUBLIC_API_URL to point at the backend. This is the obvious approach and it works — until you deploy. NEXT_PUBLIC_* variables are baked into the client bundle at build time on Vercel. If your backend URL changes (Railway redeploys, preview environments, staging), you’re rebuilding the frontend.
More importantly, exposing the backend URL in the client bundle means it shows up in source maps, browser network tabs, and any static analysis. For a healthcare app, I don’t want that URL visible any more than necessary.
ADR 002 fix: Remove NEXT_PUBLIC_API_URL. Add a server-side-only BACKEND_URL to frontend/.env.local. Add a rewrite rule in next.config.ts:
async rewrites() {
return [
{
source: '/v1/:path*',
destination: `${process.env.BACKEND_URL}/v1/:path*`,
},
];
}
Now all client components call relative paths like axios.get('/v1/providers'). The Next.js server proxies the request to the backend at runtime, using BACKEND_URL that never leaves the server. The backend URL is never in the client bundle.
See docs/adr/002-nextjs-rewrite-proxy-for-backend.md for the full record.
ADR 003: signOut callbackUrl must use window.location.origin
This one cost me an afternoon.
signOut({ callbackUrl: '/' }) is supposed to redirect to the homepage after logout. In development, when the frontend is running on port 3002 (because 3000 was taken), it redirected to http://localhost:3000/ — the wrong port. Every time.
The root cause: NextAuth resolves relative callbackUrl values against NEXTAUTH_URL. If NEXTAUTH_URL=http://localhost:3000 and the app is actually on port 3002, you get redirected to the wrong place.
The fix:
signOut({ callbackUrl: window.location.origin })
window.location.origin is always the actual running origin — the port you’re on right now. NEXTAUTH_URL is still required by NextAuth for CSRF protection and is set correctly in the env file. It just shouldn’t be used as the redirect target when you need the real current origin.
See docs/adr/003-nextauth-signout-callback-url.md.
Two axios instances
The frontend has two ways to call the backend:
-
lib/api.ts— an authenticated axios instance withbaseURL: '/v1'. A request interceptor attaches the JWT fromgetSession()as a Bearer token. Every patient or provider action that requires login uses this. -
Plain
axios.get('/v1/...')— unauthenticated calls used by the public provider search and provider detail pages. No interceptor, no session lookup. The backend endpoints these hit have noJwtAuthGuard.
Keeping these separate means it’s obvious at the call site whether a request requires auth. You don’t have to trace through a shared client to figure out whether a token is being sent.
Key files:
frontend/next.config.tsfrontend/lib/auth.tsfrontend/lib/api.tsdocs/adr/002-nextjs-rewrite-proxy-for-backend.mddocs/adr/003-nextauth-signout-callback-url.md
// comments via github discussions