How to Fix: cookie can’t modified in “use server” file

6 min read

The bug is not in your cookie API call itself—it is in where that call runs. In Next.js 15, cookies can only be mutated inside places that own the outgoing HTTP response, such as a Server Action or Route Handler. A shared use server file like protect.ts may run on the server, but that does not automatically mean it can modify response cookies.

Understanding the Root Cause

Next.js separates reading cookies from writing cookies.

  • Reading cookies: allowed in more server-side contexts, including Server Components.
  • Writing cookies: only allowed when Next.js can attach a Set-Cookie header to the HTTP response.

That is why code like this often fails when placed in a utility file such as protect.ts:

'use server'

import { cookies } from 'next/headers'

export async function protect() {
  const cookieStore = await cookies()
  cookieStore.set('token', 'abc')
}

Even though the file uses 'use server', the function is still just a server-side module function unless it is executed in a valid mutation context. Next.js blocks the write because there is no guaranteed response object available for that call site.

In practice, this issue usually appears in one of these cases:

  • You call cookies().set() inside a shared helper imported by a Server Component.
  • You try to update cookies during rendering.
  • You put auth logic in a server-only file and expect it to behave like a Route Handler.

The fix is to move cookie mutation into one of the supported boundaries:

  • Route Handlers such as app/api/.../route.ts
  • Server Actions triggered from forms or client interactions
  • In some auth flows, middleware can also read and set cookies, depending on the use case

Step-by-Step Solution

The safest pattern is: keep protect.ts as a pure auth/helper module, and move actual cookie writes into a Route Handler or Server Action.

1. Do not mutate cookies directly inside a shared helper

Refactor protect.ts so it only computes values or validates state.

import { cookies } from 'next/headers'

export async function getAuthCookie() {
  const cookieStore = await cookies()
  return cookieStore.get('token')?.value ?? null
}

export async function shouldRefreshToken() {
  const cookieStore = await cookies()
  const token = cookieStore.get('token')?.value

  if (!token) return false

  // your validation logic here
  return true
}

If you need to set, refresh, or delete a cookie during authentication, create an API route.

import { NextResponse } from 'next/server'

export async function POST() {
  const response = NextResponse.json({ success: true })

  response.cookies.set('token', 'new-token-value', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
  })

  return response
}

This works because NextResponse owns the outgoing response and can attach the Set-Cookie header correctly.

If your cookie update is triggered by a form submit or a UI action, use a Server Action.

'use server'

import { cookies } from 'next/headers'

export async function loginAction() {
  const cookieStore = await cookies()

  cookieStore.set('token', 'new-token-value', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
  })
}

Then call it from a form or client interaction:

import { loginAction } from './actions'

export default function LoginForm() {
  return (
    <form action={loginAction}>
      <button type="submit">Login</button>
    </form>
  )
}

This works because a Server Action runs in a mutation-capable request/response cycle.

4. If you are protecting routes, separate validation from mutation

A common mistake is trying to refresh tokens while checking access inside a protection helper. Instead:

  • Use protect.ts to verify whether a user is authenticated.
  • Use a Route Handler to refresh or rotate tokens.
  • Redirect unauthenticated users from the page or layout layer.
import { redirect } from 'next/navigation'
import { getAuthCookie } from '@/lib/protect'

export default async function DashboardPage() {
  const token = await getAuthCookie()

  if (!token) {
    redirect('/login')
  }

  return <div>Dashboard</div>
}

5. If you need middleware behavior, use middleware explicitly

For request-time auth checks, use middleware.ts instead of trying to make a helper behave like middleware.

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/dashboard/:path*'],
}

If you need to set a cookie in middleware, do it on the returned NextResponse, not in a random helper.

Depending on the version, cookies() may be asynchronous in current examples and docs. Use the modern pattern consistently:

import { cookies } from 'next/headers'

export async function example() {
  const cookieStore = await cookies()
  return cookieStore.get('token')
}

For the latest behavior and API details, check the Next.js cookies documentation.

Common Edge Cases

Calling a Server Action like a normal function

If you import a supposed action and invoke it during rendering, Next.js may not treat it as a valid cookie mutation boundary. A Server Action should be triggered through supported action flows.

Trying to set cookies in a Server Component render path

Server Components can read request data, but rendering is not the same thing as building a mutable response. If your page, layout, or helper runs during render, cookie writes can fail.

Refreshing auth tokens inside a utility imported everywhere

This is a design trap. Shared utilities should usually return auth state, not perform response mutation. Put token refresh in a dedicated endpoint or action.

A cookie may appear not to update if attributes differ from what the browser expects. Double-check path, httpOnly, secure, sameSite, and expiration settings.

Development vs production behavior

If you set secure: true locally over plain HTTP, the browser may ignore the cookie. Use:

secure: process.env.NODE_ENV === 'production'

Deletion must match the correct path and attributes in many cases. If the original cookie was set on /, delete it with the same path context.

response.cookies.set('token', '', {
  expires: new Date(0),
  path: '/',
})

FAQ

Why does 'use server' not guarantee that I can modify cookies?

Because 'use server' only marks code as server-executed. Cookie mutation additionally requires a context that can send a Set-Cookie header in the HTTP response, such as a Route Handler or Server Action.

Can I read cookies in protect.ts but not write them?

Yes. Reading is allowed in more places. Writing is restricted to response-aware contexts.

What is the best fix for auth protection in this issue?

Use protect.ts for auth checks only, then move cookie updates like login, refresh, logout, or token rotation into a Route Handler or Server Action. That matches how Next.js 15 expects response mutations to work.

In short, the issue happens because server code is not always the same as response-mutation code. Once cookie writes are moved out of protect.ts and into a proper Next.js response boundary, the bug disappears.

If you want to compare this behavior against the original reproduction, review the project linked in the issue: reproduction repository.

Leave a Reply

Your email address will not be published. Required fields are marked *