# 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:
```ts
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}`:
```tsx
{user ? (
… {children} …
) : (
// ← 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.
### Attempt 3 — store the path in a cookie: runtime error
**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 `` — 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:
```tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ComponentProps } from "react";
export function LoginLink(props: Omit, "href">) {
const pathname = usePathname();
return ;
}
```
2. **`src/components/frontend/LandingPage.tsx`** — the CTA swaps `` for
``. 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:
```tsx
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 ;
}
```
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:
```bash
# 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.