Stop the sign-out link from signing you out on render

Sign-out answered GET behind a <Link>, and Next prefetches links that are in the
viewport. The button sits in every page header, so the browser called it as soon
as a page rendered and cleared the session cookie before the reader had clicked
anything. The first page loaded; the next navigation landed back on the password
screen, which read as the session not persisting.

It is a POST form now, and the route answers POST only — a prefetch of it gets
405 and changes nothing. The rule this broke is the general one: an endpoint
with a side effect must be unreachable by a prefetch, a crawler, or a browser
preloading what it thinks you will click.

Both session cookies are now set on the returned response rather than through
the cookies() store, which a freshly constructed redirect does not reliably
carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TCFDONMEZ
2026-10-08 14:35:58 +03:00
co-authored by Claude Opus 5
parent 84abd75104
commit b45874c405
5 changed files with 39 additions and 28 deletions
+6 -5
View File
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { SESSION_COOKIE, checkDashboardPassword, issueSessionValue, sessionCookieOptions } from "@/lib/auth";
import { SESSION_COOKIE, checkDashboardPassword, issueSessionValue, sessionCookieOptions } from "@/lib/session";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -17,7 +16,9 @@ export async function POST(request: Request) {
await new Promise((resolve) => setTimeout(resolve, 250));
return NextResponse.redirect(new URL("/login?error=1", request.url), { status: 303 });
}
const store = await cookies();
store.set(SESSION_COOKIE, issueSessionValue(), sessionCookieOptions);
return NextResponse.redirect(new URL("/", request.url), { status: 303 });
const response = NextResponse.redirect(new URL("/", request.url), { status: 303 });
// Set on the response rather than through the cookies() store: a freshly constructed
// redirect does not reliably carry what that store recorded.
response.cookies.set(SESSION_COOKIE, issueSessionValue(), sessionCookieOptions);
return response;
}
+1
View File
@@ -353,6 +353,7 @@ a {
}
.signout {
font: inherit;
font-size: 12px;
color: var(--muted);
background: none;
+16 -6
View File
@@ -1,12 +1,22 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { SESSION_COOKIE } from "@/lib/auth";
import { SESSION_COOKIE } from "@/lib/session";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const store = await cookies();
store.delete(SESSION_COOKIE);
return NextResponse.redirect(new URL("/login", request.url));
/**
* POST only, deliberately.
*
* This used to answer GET behind a `<Link>`, so Next's viewport prefetch called it on
* every render and cleared the cookie before the reader had clicked anything — which
* presented as the session expiring on every navigation. Anything that changes state has
* to be unreachable by a prefetch, a crawler or a preloading browser, and that means not
* answering GET.
*/
export async function POST(request: Request) {
const response = NextResponse.redirect(new URL("/login", request.url), { status: 303 });
// Cleared on the response itself rather than through the cookies() store: a freshly
// constructed redirect does not reliably carry what that store recorded.
response.cookies.set(SESSION_COOKIE, "", { path: "/", maxAge: 0 });
return response;
}