afaqrashid.com  ·  portfolio · 2026 ed.

"Cookies can only be modified in a Server Action or Route Handler": Setting Cookies in Next.js, Tested

In the App Router you can read cookies anywhere on the server, but you can only set or delete them in three places: a Server Action, a Route Handler, or proxy (what middleware is called from Next.js 16). Call cookies().set() in a page, layout or server component and it throws.

I tested each place on Next.js 16.4.4 with a production build and checked the Set-Cookie headers each one sent.

The error, and why it happens

Error: Cookies can only be modified in a Server Action or Route Handler.
Read more: https://nextjs.org/docs/app/api-reference/functions/cookies#options

A page is HTML being streamed to the browser, and the response headers (where Set-Cookie lives) have already gone by the time your component runs. So Next refuses instead of silently dropping the cookie.

cookies().set() in a page             result
not caught                            500 Internal Server Error
inside try/catch                      200, no Set-Cookie header, cookie never set

Wrapping it in try/catch is the trap: the page loads and nothing tells you the cookie was never sent. If you need a cookie set on page load, move the code to proxy (below).

1. Server Action: for forms and buttons

"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

export async function setTheme() {
  (await cookies()).set("theme", "dark", {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 30, // 30 days, in seconds
    path: "/",
  });
}

export async function clearTheme() {
  (await cookies()).delete("theme");
}

export async function saveAndGo() {
  (await cookies()).set("flash", "saved", { path: "/" });
  redirect("/done");
}

Wired to <form action={setTheme}>, the page re-rendered after the click and showed theme=dark without a reload: setting a cookie in an action refreshes the current page's server components. Because it was httpOnly, document.cookie in the browser didn't contain it, which is what you want for a session. After clearTheme the page showed an empty theme.

Set then redirect worked too: the browser landed on /done and that page read flash=saved. Call redirect() after setting the cookie, not before, since redirect() throws and nothing after it runs.

One more check: secure: true still worked on http://localhost, because Chrome treats localhost as a secure origin. On another plain http host (a LAN IP, a staging server without TLS) the browser drops secure cookies.

2. Route Handler: for API endpoints and OAuth callbacks

// app/api/login/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

export async function GET() {
  (await cookies()).set("a", "from-cookies-api");
  const res = NextResponse.json({ ok: true });
  res.cookies.set("b", "from-response");
  return res;
}
HTTP/1.1 200 OK
set-cookie: b=from-response; Path=/
set-cookie: a=from-cookies-api; Path=/

Both ways work in a Route Handler and they combine: cookies() from next/headers and res.cookies on the response you return. Use whichever reads better; the response version is handy when you build a redirect with NextResponse.redirect().

3. Proxy: for cookies on page load

// proxy.ts, next to app/ (or in src/ if you use src/app)
import { NextResponse } from "next/server";

export function proxy() {
  const res = NextResponse.next();
  res.cookies.set("visited", "yes");
  return res;
}

export const config = { matcher: "/pricing" };

This is the answer to "set a cookie when someone opens a page". The response carried set-cookie: visited=yes; Path=/, and the page's own cookies() saw the new value in that same request, so you don't need a second visit before the server can use it. In my test setting it on the request as well (request.cookies.set plus NextResponse.next({ request })) also worked.

Check where the file is if nothing happens: proxy.ts placed inside app/, or at the project root when the app lives in src/app, didn't run at all and gave no error. Details are in the middleware-to-proxy post linked below.

Which one to use

  • A user clicks something (log in, change theme, accept cookies): Server Action.
  • An external service calls you, or you need a URL (OAuth callback, webhook, magic link): Route Handler.
  • The cookie must exist on the first request to a page (A/B bucket, referral code from a query string, locale): proxy.
  • Only reading: cookies().get() works anywhere on the server, but it makes the route dynamic. If a build fails with "Dynamic server usage" after you add it, see the post on that error.

Every result here comes from one Next.js 16.4.4 project run with next build and next start on Node 26, with the headers read from the responses and the cookies checked in Chrome.

Related

Sources