Our App Just Wouldn't Let Go
The solution was to delete the cookie from within the server action. Whenever cookies() is modified within a server action, Next itself emit the deletion Set-Cookie on the action's response, which the browser will definitely receive this time.
Victor Ohachor ·
Some bugs kiss you on your right cheek, only to be your Judas...
One of such I experienced today. I had just integrated Cloudflare's Turnstile to prevent non-users from spamming the prompter, which shows up on the landing, product, and about pages.
I wanted to test my work in production, since I had disabled reCAPTCHA in the development environment. Hence, I signed out, found myself on the landing page (as expected), smile, and typed into the prompter. As I typed, I thought:
"Turnstile is not showing up... Hmmm... That's weird..."
I had already set TURNSTILE_SECRET_KEY on the backend and NEXT_PUBLIC_TURNSTILE_SITE_KEY on the frontend. I was sure that Turnstile was working before I deployed.
So, I assumed there was a caching problem.
I clicked GENERATE anyway.
Usually, as a non-user, when you click the prompter's button, you are sent to /result/[searchId]?sid=<sid>...But I found myself on the dashboard result page (/dashboard/history/[searchId]). Since I was signed out, this shouldn't have happened.
But...but... it did! Why?!
As a debugging rule, I clicked the SIGN OUT button. I needed to replicate what happened. Since my assumption was that something could be wrong with the sign out flow, I clicked the DASHBOARD button again once on the landing page.
To my horror, I found myself on the dashboard result page again, a new search initiated using same prompt from before.
The best part about discovering disastrous bugs like this is when you find it before your users. Assuming a user had first initiated a search as a non-user, then signed in, signed out again, and then signed in, they would experience this bug.
But the likelihood of that happening is pretty low. But don't underestimate malicious/curious users, users always looking for something interesting or a way to mess you up.

So... What is the Bug?
There were two (2) bugs. Ordinary bugs. Each mild on its own. But they had a multiplying effect that felt supernatural.
Sign-out was an amazing illusion
I was actually signed out when I clicked the SIGN OUT button. One moment, I was on the dashboard; another, I was on the landing page. Isn't that the sign-out experience?
In fact, the app made a request to the logout API endpoint. So, what happened?
The logout was a Next.js server action:
export async function signOut() {
const jar = await cookies()
const cookie = jar.getAll().map((e) => `${e.name}=${e.value}`).join("; ")
try {
await fetch(`${API_ORIGIN}/api/v1/auth/logout`, {
method: "POST",
headers: cookie ? { cookie } : {},
})
} catch {
// fall through to local redirect either way
}
redirect("/")
}
The backend did an excellent job: it responded with Set-Cookie: sid=; Max-Age=0, which would have properly expired the session cookie. I said "would have" because the browser never saw it.
Logout is a server action, hence the fetch happened server-to-server. Response headers of a server-side fetch are for Node to inspect and are not forwarded to the client that initiated the action.
redirect('/') then makes the user believe they have been logged out, haha.
The solution was to delete the cookie from within the server action. Whenever cookies() is modified within a server action, Next itself emit the deletion Set-Cookie on the action's response, which the browser will definitely receive this time.
Hence adding the line before the redirect fixed the issue:
// The API's Set-Cookie deletion header never reaches the browser through
// this server-side fetch, so drop the session cookie locally too.
jar.delete("sid")
redirect("/")
Brief wey no gree die
This was the second bug, particularly responsible for the duplicate search. The landing page had a handoff pattern:
Here is the code snippet responsible for this:
sessionStorage.setItem(PENDING_BRIEF_KEY, value)
router.push(`/dashboard?brief=${encodeURIComponent(value)}`)
And the dashboard's GenerateAndGo component was supposed to consume it exactly once:
useEffect(() => {
if (submittedRef.current || pending) return
let brief = initialBrief // <- from ?brief= URL param
if (!brief) { // <- ONLY clears the stash on this path
brief = sessionStorage.getItem(PENDING_BRIEF_KEY) ?? ""
sessionStorage.removeItem(PENDING_BRIEF_KEY)
}
if (brief.trim()) void onSubmit(brief.trim())
}, [initialBrief])
Read it slowly: the removeItem lives inside the if (!brief) branch. On the main path (the path the prompter just took that was arriving with ?brief=), the stash was read from the URL and sessionStorage was not touched.
The fix was super simple... Make consumption unconditional, so that a later visit can't auto-start a phantom search.
let stored = ""
try {
stored = sessionStorage.getItem(PENDING_BRIEF_KEY) ?? ""
if (stored) sessionStorage.removeItem(PENDING_BRIEF_KEY)
} catch {
stored = ""
}
const brief = (initialBrief || stored).trim()
if (brief) {
submittedRef.current = true
const id = setTimeout(() => void onSubmit(brief), 0)
return () => clearTimeout(id)
}
Lessons???
You tell me!