Cookies
remix/cookie is the low-level building block. Sessions, auth, and CSRF protection all hang off Cookie instances internally — and you can use them directly for anything else (locale prefs, dark-mode flag, A/B bucket, …).
Imports
import { createCookie } from 'remix/cookie'
Create a cookie
const sessionCookie = createCookie('__session', {
secrets: ['s3cret1'], // required for signed cookies
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 days
})
| Option | Notes |
|---|---|
secrets |
Array of HMAC keys. First entry signs new cookies. Others verify old ones — that's how rotation works (prepend a new key, leave the old one for a transition window). |
httpOnly |
Hides the cookie from JS — set to true for anything sensitive. |
secure |
HTTPS-only. true in prod, false for local HTTP. |
sameSite |
'Strict' / 'Lax' / 'None' (capitalised per the type). 'Lax' is the safe default for top-level nav flows. |
maxAge |
Seconds. Omit for a session cookie (dies with the browser). |
path |
Defaults to /. |
domain |
Set for cross-subdomain cookies. |
Parse from a request
const value = await sessionCookie.parse(request.headers.get('Cookie'))
// value is `null` if the cookie is absent or the signature is invalid
Serialize for a response
const setCookie = await sessionCookie.serialize({ userId: 42 })
return new Response('OK', {
headers: { 'Set-Cookie': setCookie },
})
Pass an empty value + maxAge: 0 to clear a cookie.
Secret rotation
You're rotating a cookie secret. The procedure:
- Prepend the new secret to the
secretsarray. Don't replace.secrets: [process.env.NEW_SECRET!, process.env.OLD_SECRET!] - Deploy. All new cookies are signed with
NEW_SECRET. Existing cookies signed withOLD_SECRETstill verify. - After the rollout window (
maxAgeis a good upper bound), dropOLD_SECRET.
If you replace instead of prepending, every active session is invalidated.
Sessions use this under the hood
import { createCookie } from 'remix/cookie'
import { createCookieSessionStorage } from 'remix/session/cookie-storage'
import { session } from 'remix/session-middleware'
const sessionCookie = createCookie('__session', {
secrets: [process.env.SESSION_SECRET!],
httpOnly: true,
secure: true,
sameSite: 'Lax',
})
const sessionStorage = createCookieSessionStorage()
createRouter({ middleware: [session(sessionCookie, sessionStorage)] })
The session middleware calls parse on the way in and serialize on the way out for you.
A self-contained example — locale preference
// app/cookies/locale.ts
import { createCookie } from 'remix/cookie'
export const localeCookie = createCookie('locale', {
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/',
sameSite: 'Lax',
// No `secrets` → unsigned, fine for a non-sensitive preference
})
// reading
const locale = (await localeCookie.parse(request.headers.get('Cookie'))) ?? 'en'
// writing
return new Response(html, {
headers: { 'Set-Cookie': await localeCookie.serialize('fr') },
})
Common pitfalls
- Forgetting
secretson a session cookie. Signed cookies requiresecrets. Sessions in particular must be signed. - Mixing signed and unsigned. A given cookie is either signed or not — don't flip mid-deployment without rotating the name.
secure: trueover local HTTP. Browsers silently drop the cookie. Usesecure: process.env.NODE_ENV === 'production'.- Long
maxAgeon JWT-style sessions. If you store auth state in the cookie itself, treat the cookie as a credential and keepmaxAgeshort or pair with a server-side store.