1
0
Fork 0
polaris-task-force/docs/case-studies/login-return-url.md
Z8MB1E a8e147e52a docs: add AGENTS.md subdirectories and login case study
Add domain-specific AGENTS.md files for collections, components, lib,
utils, and bot. Add login-return-url case study documenting the
return-URL flow design decisions and debugging lessons learned.
2026-08-19 19:48:35 -04:00

11 KiB

Case study: preserving the return URL through login

How a "remember where I was going" feature was implemented (and mis-implemented three times before that) in this repo. Written for agents — especially smaller local models — as a pattern to copy and a set of anti-patterns to recognize early. Every wrong turn below was really taken; the point of writing it down is that each failure had a signal that said "stop, your model is wrong" long before anyone listened to it.

The distilled rules live in AGENTS.md → "Agent debugging SOP" and "Next.js 16 hard rules". This document is the evidence behind them.

The task

Unauthenticated users who navigate to a protected page (e.g. /flappy) should, after logging in, land back on that page instead of the dashboard.

The symptom (user report #1)

I just logged out, went to /flappy, found the landing page and was redirected to /login (no url params!), logged in, and was brought to the dashboard instead of back to Flappy.

The failure arc

Attempt 1 — redirect from the page: never runs

What was done: the login page and LoginForm were taught to read a returnTo query param, and the protected pages (flappy, helpdesk) got guest blocks like:

if (!user) {
  const pathname = headers.get("x-invoke-path") || "/flappy";
  redirect(`/login?next=${encodeURIComponent(pathname)}`);
}

Why it seemed reasonable: pages own their auth checks; redirect() is the canonical Next.js way to bounce unauthenticated users.

What actually happened: no query param appeared at all. The user landed on /login bare and went to / after login.

Why it failed (two independent reasons):

  1. The page never rendered. src/app/(frontend)/layout.tsx is the real gate, and for guests it replaces {children}:

    {user ? (
      <SidebarProvider>… {children} …</SidebarProvider>
    ) : (
      <LandingPage />   // ← guests never reach {children}
    )}
    

    Every if (!user) redirect(...) inside a page under (frontend) is dead code for guests. The redirect() never executes because the page component never renders.

  2. Producer/consumer mismatch. The pages emitted ?next=…; the login page read searchParams.returnTo. Even if the redirect had fired, the param would have been ignored. Query-param mismatches fail silently — there is no error, just nothing.

The signal that was missed: the user said "I found the landing page" — the landing page rendering at all proves the page body (and its redirect) never ran. That was the clue.

Attempt 2 — move the redirect into the layout: ?next=%2F

What was done: the same x-invoke-path redirect logic was moved into the layout, firing for every guest.

What actually happened (user report #2):

I did see ?next= appear, but it was actually ?next=%2F despite expecting ?next=/flappy.

Why it failed: headers.get("x-invoke-path") does not reliably contain the current pathname in Next.js 16 layouts — here it returned / (or nothing, hitting the || "/" fallback). Read the symptom the right way: %2F is not a mangled /flappy; it is the default value leaking through. When you observe a default instead of your data, the data source was empty. Mangling was never on the table.

Bonus failure: this also silently changed product behavior — guests lost the landing page entirely and got an instant redirect instead. The task never asked for that.

What was done: since the layout couldn't put the path in the URL, it tried to stash it in a cookie: cookies().set("loginRedirect", pathname, …) in the layout body, then redirect("/login").

What actually happened (user report #3):

Error: Cookies can only be modified in a Server Action or Route Handler.
    at RootLayout (src/app/(frontend)/layout.tsx:68:16)

Why it failed: in Next.js App Router, server components (layouts/pages) cannot mutate cookies. cookies() from next/headers is read-only there. Writes are only legal in Server Actions and Route Handlers. The framework error message is precise and correct — read it literally instead of routing around it.

Attempt 4 — referer fallback: complexity accretion

What was done: with x-invoke-path proven useless, a referer-header fallback was added on top of the cookie approach (and then a second, duplicated copy of the same fallback block).

Why it failed: referer is empty on direct navigation (typing a URL), and when present it points at the previous page, not the requested one. It cannot answer this question by construction. This attempt also demonstrates the worst failure pattern of the whole session: when a fix fails, adding a fallback on top of it preserves the wrong assumption and adds code. By now there were three mechanisms layered (header → cookie → referer), none of which could work, and duplicated code on top.

This is the point where the correct move was: stop editing. Two-plus failed fixes means the mental model is wrong, not the implementation.

The turn: trace the render path

Re-reading the flow instead of patching it produced the key facts:

  1. Guests never reach page code — the layout's LandingPage branch is the whole guest experience. So the path can only be captured where the guest actually is: inside the landing page.
  2. The landing page's CTA was a plain <Link href="/login"> — a static link with no knowledge of where the user is standing. That is the navigation point the whole feature hangs on, and it is the thing that should carry the path.
  3. Server components cannot reliably know the current path (x-invoke-path unreliable, no request URL in layouts). The current path is reliably known by usePathname() in client components — exactly at the point where the user clicks "log in".

Rule of thumb that falls out: attach data where the fact is known, at the point the navigation happens — not where it is convenient to compute.

The fix (4 small changes)

  1. src/components/frontend/auth/LoginLink.tsx (new, ~8 lines) — a client component that knows the current path and builds the link:

    "use client";
    
    import Link from "next/link";
    import { usePathname } from "next/navigation";
    import type { ComponentProps } from "react";
    
    export function LoginLink(props: Omit<ComponentProps<typeof Link>, "href">) {
      const pathname = usePathname();
      return <Link {...props} href={`/login?returnTo=${encodeURIComponent(pathname)}`} />;
    }
    
  2. src/components/frontend/LandingPage.tsx — the CTA swaps <Link href="/login"> for <LoginLink>. A guest at /flappy now gets href="/login?returnTo=%2Fflappy".

  3. src/app/login/page.tsx — awaits searchParams (Next 16: it's a Promise), sanitizes the target against open redirects, sends already-authed users straight to their target:

    function safeReturnTo(value: string | undefined): string | undefined {
      if (!value?.startsWith("/") || value.startsWith("//")) return undefined;
      return value;
    }
    
    export default async function LoginPage({ searchParams }: {
      searchParams: Promise<{ returnTo?: string }>;
    }) {
      const { returnTo } = await searchParams;
      const target = safeReturnTo(returnTo);
      // …
      if (user) redirect(target ?? "/");
      return <LoginForm returnTo={target} />;
    }
    
  4. src/components/frontend/auth/LoginForm.tsx — after a successful login POST, router.push(returnTo ?? "/"). (Also: dropped returnTo from the POST body — the API never read it; the redirect is purely client-side.)

And reverted the layout to its original guest flow, plus removed the dead page-level redirect blocks' reliance on x-invoke-path. Net result: less code than the failing versions.

The verification matrix

Run against a live dev server with the dev user (dev / Test123). Copy-pasteable:

# 1. Guest hits the protected route — landing page renders, CTA carries the path
curl -s http://localhost:3000/flappy | grep -o 'href="/login?returnTo=[^"]*"'
# expect: href="/login?returnTo=%2Fflappy"

# 2. Login page passes returnTo through to the form
curl -s "http://localhost:3000/login?returnTo=%2Fflappy" | grep -o '%2Fflappy'
# expect: %2Fflappy (present in the RSC payload)

# 3. Login API sets the auth cookie
curl -s -X POST http://localhost:3000/api/users/login \
  -H 'content-type: application/json' \
  -d '{"username":"dev","password":"Test123"}' -c /tmp/cookies.txt -o /dev/null -w "%{http_code}\n"
# expect: 200

# 4. Authed user reaches the protected page
curl -s -b /tmp/cookies.txt http://localhost:3000/flappy -o /dev/null -w "%{http_code}\n"
# expect: 200

# 5. Authed user hitting /login?returnTo=... bounces straight to the target
curl -s -b /tmp/cookies.txt -o /dev/null \
  -w "%{http_code} -> %{redirect_url}\n" "http://localhost:3000/login?returnTo=%2Fflappy"
# expect: 307 -> http://localhost:3000/flappy

All five passed against the running dev server before the work was reported done. Note the dev-server subtlety encountered along the way: a spawned bun run dev detected an already-running server, exited, and the curls actually hit the existing hot-reloaded server — which is fine (that's the surface the user sees), but know which process you're testing against. Check the dev log for "Another next dev server is already running" and its PID.

Signals you are on the wrong path (recognize these early)

  • A default value shows up instead of your data (?next=%2F). The data source is empty. Find out why it's empty — don't post-process the value.
  • The component you're editing never renders for the scenario you're fixing (guests and {children} replacement). Verify by asking what the user saw — if they saw the landing page, page code didn't run.
  • A framework error message names a restriction ("Cookies can only be modified in a Server Action or Route Handler"). It is stating a rule, not a bug. Restructure to comply; don't fight it.
  • You're adding a second or third fallback. Each fallback is an admission the previous model was wrong — while keeping it. Stop and re-derive instead.
  • The fix changes behavior the task never asked about (landing page disappears). Scope creep during a bug fix is a sign the approach is wrong, not a bonus.

The rules (mirror of the AGENTS.md SOP)

  1. Trace the real render path before writing any fix.
  2. Treat every framework API as a hypothesis; verify it.
  3. Two failed fixes = wrong mental model. Stop, re-read, find the wrong assumption.
  4. Attach data where the fact is known (usePathname() client-side), at the point of navigation.
  5. Match producer and consumer (next vs returnTo fails silently).
  6. Verify end-to-end (curl matrix above) before reporting done.
  7. Restate before acting: assumptions → plan → verification command.