TLDR: router.replace(?foo=bar) on a force-dynamic page hits the server every single time. Use React state + window.history.replaceState for UI state. Save router.replace for actual navigation.

the setup

I've been building an internal event project manager for an ecommerce business to replace our Notion V3 framework.

Next.js 16 App Router. Supabase on the back end. Clean stack.

One of the core UX patterns was inline task editing — click a task row, and it opens right there. No slide-out sheet, no page transition. Just the row becoming editable, in place.

Like Things 3 does it. (Things 3, the beloved Mac task manager — the gold standard for how task UX should feel.)

the wall

My first implementation was… technically reasonable.

Click a task row → router.replace('?task=123') → read searchParams.task in the component → if it matches, expand that row.

URL-driven state. Deep-linkable. The kind of thing that feels correct.

And then I actually used it.

Click a task. And… waited.

My exact note: "Distinct delay when I click a task and it's able to be edited."

Not imperceptible. Distinct. Every. Single. Click.

On localhost dev it was 300–600ms. On prod, a little less — but still there, and it stacked on every interaction.

what I missed

I assumed a search-param-only router.replace was a cheap client-side URL flip.

It is not. Not when your page has export const dynamic = "force-dynamic".

That directive tells Next.js: re-run this page on the server on every request. Which means any navigation — even a param-only URL update — triggers a full RSC (React Server Component, Next's server-render layer) roundtrip. The server re-executes the page, generates a fresh payload, and sends it down. Then the client re-renders. Then the autofocus fires.

The user's expectation is "click → instant." What they got was "click → wait → edit mode."

the fix that worked

Strip URL state from the expand/collapse logic entirely — for in-app interactions.

I switched to a TaskExpansionProvider, a plain React Context holding a Set of expanded task IDs. Click row → setState. Sub-frame. Done.

For the URL, I kept ?task=N alive using window.history.replaceStatenot router.replace. This writes the address bar without triggering any React Router navigation, without touching the RSC layer at all.

// ✅ Instant — no navigation, no server hit
window.history.replaceState(null, '', `?task=${taskId}`)

// ❌ RSC roundtrip on force-dynamic pages
router.replace(`?task=${taskId}`)

Deep links still work — the provider seeds from useSearchParams on initial mount, so ?task=123 in a notification or a share link still lands on the right open row.

One lint trap to know: the react-hooks/set-state-in-effect rule fires when you sync local state from a URL param in a useEffect. The fix is a functional-updater short-circuit (setX(cur => cur === next ? cur : next)) and a targeted // eslint-disable-next-line on that exact line (not the useEffect declaration — that won't cover the body).

why this matters to me

Speed isn't just a metric.

A 400ms lag on a click doesn't break a task manager. But it makes you feel like you're fighting the tool instead of using it — and that kills the habit. I wanted something I'd actually reach for every day.

The rule I'm carrying forward: if the user would be surprised to see a loading indicator, it's UI state — keep it local. Use router.replace for real navigation. Use window.history.replaceState for everything that's just syncing the address bar to what's already on screen.