
How to read and set cookies in Astro?
To read and set cookies in Astro, use the Astro.cookies object in pages and components, or context.cookies in API endpoints, middleware and actions. Read a value with Astro.cookies.get('name')?.value, check whether one exists with has(), write one with set('name', value, options) and remove it with delete('name'). Astro collects every cookie you set during the request and adds the matching Set-Cookie headers to the response for you. The one rule to remember is that cookies only exist at request time, so the page or endpoint that uses them must be rendered on demand, not prerendered.
Cookies are the simplest way to remember something about a visitor between requests: a theme choice, a language, a consent flag or a session ID. Astro wraps the raw Cookie and Set-Cookie headers in a small, typed API so you don't have to parse strings yourself. In this article you'll learn where cookies are available, how to read values as strings, numbers, booleans and JSON, which options to use when setting them, how to delete them correctly, how to work with them in endpoints and middleware, and how to avoid the mistakes that make cookies silently disappear.
Where Cookies Are Available
Cookies come from the incoming HTTP request, so they're only meaningful when there is a real request to read. That means:
- On-demand pages: pages with
export const prerender = false, or every page when your config usesoutput: 'server'. These need an adapter. See enabling server-side rendering in Astro. - API endpoints: files in
src/pagesthat exportGET,POSTand other handlers, rendered on demand. - Middleware: through
context.cookies, for on-demand routes. - Actions: through the handler's
context.cookies.
Prerendered pages are built once, at build time, with no visitor attached. If you try to read cookies in one, Astro warns you during the build:
[WARN] `Astro.request.headers` was used when rendering the route `src/pages/static-cookie.astro'`. `Astro.request.headers` is not available on prerendered pages.
The value you get back is simply empty, and whatever you set is thrown away because there's no response to attach it to. If your site is mostly static, opt individual pages into on-demand rendering. The post on mixing prerendered and on-demand pages shows how.
Reading Cookies
Astro.cookies.get() returns an AstroCookie object, or undefined if the cookie isn't present:
---
// src/pages/theme.astro
export const prerender = false;
const theme = Astro.cookies.get('theme')?.value ?? 'light';
---
<html lang="en" data-theme={theme}>
<body>
<p>Theme: {theme}</p>
</body>
</html>
The optional chaining (?.) matters. A first-time visitor has no cookies, and calling .value on undefined would throw.
Converting the Value
Cookies are always strings on the wire. AstroCookie gives you helpers to convert them:
| Method | Returns | Notes |
|---|---|---|
value | string | The raw, decoded value |
number() | number | NaN if the value isn't a valid number |
boolean() | boolean | Converts the value to a boolean |
json() | Record<string, any> | Runs JSON.parse(), throws on invalid JSON |
Because json() throws on bad input, wrap it in try...catch whenever a visitor could have tampered with the cookie, which is always. number() returning NaN is easy to guard with || 0 or Number.isFinite().
Checking Whether a Cookie Exists
has() returns a boolean without reading the value:
if (Astro.cookies.has('consent')) {
// load analytics
}
Both get() and has() look at cookies set during the current request first, then at the incoming request. So if you set() a cookie earlier in the same request, a later get() sees the new value. That's convenient, because you can update a preference and render with it in one pass.
Setting Cookies
set() takes a name, a value and an options object:
---
// src/pages/theme.astro
export const prerender = false;
const allowed = ['light', 'dark'] as const;
type Theme = (typeof allowed)[number];
const requested = Astro.url.searchParams.get('theme');
if (requested && allowed.includes(requested as Theme)) {
Astro.cookies.set('theme', requested, {
path: '/',
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
});
}
const theme = Astro.cookies.get('theme')?.value ?? 'light';
const visits = (Astro.cookies.get('visits')?.number() || 0) + 1;
Astro.cookies.set('visits', String(visits), { path: '/', httpOnly: true });
---
<html lang="en" data-theme={theme}>
<body>
<p>Theme: {theme}. Visit number {visits}.</p>
</body>
</html>
Requesting /theme?theme=dark against the built site produced these headers and body:
set-cookie: theme=dark; Max-Age=31536000; Path=/; SameSite=Lax
set-cookie: visits=1; Path=/; HttpOnly
<p>Theme: dark. Visit number 1.</p>
And a later request that sent Cookie: theme=dark; visits=4 rendered Visit number 5 and set visits=5. Notice that the page validated the query parameter against a short list before storing it. Never write raw user input into a cookie and then trust it on the next request.
Cookie Options
These are the options set() accepts:
- path: the URL path the cookie is sent for. Use
'/'for site-wide cookies. If you leave it out, the browser defaults to the path of the current URL, which is a common cause of "my cookie only works on one page". - maxAge: lifetime in seconds. Without
maxAgeorexpires, you get a session cookie that disappears when the browser is closed. - expires: an absolute
Datewhen the cookie expires. PrefermaxAgeunless you need a fixed date. - httpOnly:
truehides the cookie fromdocument.cookie, so client-side scripts can't read it. Use this for anything sensitive. - secure:
truemeans the cookie is only sent over HTTPS. - sameSite:
'lax','strict','none'or a boolean.'lax'is a good default for most cookies. - domain: share the cookie with subdomains, such as
'.example.com'. Leave it out to keep the cookie on the current host only. - partitioned: creates a partitioned (CHIPS) cookie for third-party contexts. It requires
secure: true. - encode: a custom function for serialising the value.
Storing Objects
If you pass an object to set(), Astro converts it to JSON for you. Combined with json() on the way back, that gives you a tidy way to store a few related preferences in one cookie. Here's a small endpoint that does exactly that:
// src/pages/api/prefs.ts
import type { APIRoute } from 'astro';
export const prerender = false;
type Prefs = { lang: string; compact: boolean };
export const GET: APIRoute = ({ cookies }) => {
const prefs: Prefs = cookies.get('prefs')?.json() ?? { lang: 'en', compact: false };
return Response.json(prefs);
};
export const POST: APIRoute = async ({ cookies, request }) => {
const body = (await request.json()) as Partial<Prefs>;
const prefs: Prefs = { lang: body.lang ?? 'en', compact: Boolean(body.compact) };
cookies.set('prefs', prefs, { path: '/', maxAge: 60 * 60 * 24 * 30 });
return Response.json(prefs);
};
export const DELETE: APIRoute = ({ cookies }) => {
cookies.delete('prefs', { path: '/' });
return new Response(null, { status: 204 });
};
A POST with {"lang":"fr","compact":true} returned the object and this header. The value is URL-encoded by default:
set-cookie: prefs=%7B%22lang%22%3A%22fr%22%2C%22compact%22%3Atrue%7D; Max-Age=2592000; Path=/
Sending that cookie back to GET returned {"lang":"fr","compact":true}. Keep JSON cookies small. Browsers limit each cookie to roughly 4 KB, and every cookie is sent with every matching request, including requests for images and scripts. For anything bigger, store the data on the server with Astro sessions and keep only an ID in the cookie.
Deleting Cookies
delete() tells the browser to remove a cookie by sending it back with an expiry date in 1970:
HTTP/1.1 204 No Content
set-cookie: prefs=deleted; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
The important detail is that the browser only removes a cookie when the deletion matches the original's path and domain. If you set a cookie with path: '/' and delete it without a path, the browser treats it as a different cookie and keeps the original. Pass the same path and domain to delete() that you used in set(). It also accepts httpOnly, sameSite and secure.
Cookies in Endpoints and Redirects
In endpoints, cookies is part of the context object, next to request, url and redirect. Cookies you set are kept even when you return a redirect, which is exactly what a login handler needs:
// src/pages/api/login.ts
import type { APIRoute } from 'astro';
export const prerender = false;
export const POST: APIRoute = ({ cookies, redirect }) => {
const token = crypto.randomUUID();
cookies.set('session', token, {
path: '/',
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
return redirect('/theme', 303);
};
HTTP/1.1 303 See Other
location: /theme
set-cookie: session=9c9672bc-...; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax
secure: import.meta.env.PROD keeps the cookie usable on http://localhost during development while making it HTTPS-only in production builds. This example only shows the cookie mechanics; a real login must verify credentials and store the session server-side. The guide on adding user authentication to an Astro site covers that properly.
Cookies in Middleware
Middleware is a good place to read a cookie once and share the result with every page through locals:
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
if (context.isPrerendered) return next();
context.locals.theme = context.cookies.get('theme')?.value === 'dark' ? 'dark' : 'light';
if (!context.cookies.has('first_seen')) {
context.cookies.set('first_seen', new Date().toISOString(), {
path: '/',
maxAge: 60 * 60 * 24 * 365,
});
}
const response = await next();
response.headers.set('Vary', 'Cookie');
return response;
});
// src/env.d.ts
declare namespace App {
interface Locals {
theme: 'light' | 'dark';
}
}
Any on-demand page can now read Astro.locals.theme. The context.isPrerendered check skips the cookie logic while prerendered pages are being built, which avoids the build warning shown earlier. A request with Cookie: theme=dark returned:
set-cookie: first_seen=2026-10-24T05:04:12.324Z; Max-Age=31536000; Path=/
vary: Cookie
The Vary: Cookie header tells shared caches that the response depends on cookies, so one visitor's dark-mode page isn't served to another visitor. If you put a CDN in front of on-demand pages, this matters. More middleware patterns are in the post on what Astro middleware is and how to use it.
Reading Cookies in the Browser
Server-side cookies and client-side JavaScript can share cookies, as long as they aren't httpOnly. In a <script> you can read them with document.cookie, or with the newer Cookie Store API in browsers that support it:
<script>
const theme = document.cookie
.split('; ')
.find((row) => row.startsWith('theme='))
?.split('=')[1];
console.log(theme);
</script>
This is useful on prerendered pages: the HTML is the same for everyone, and a small script applies the visitor's preference after load. Just remember that anything readable from JavaScript is also readable by any injected script, so keep sensitive values httpOnly.
Common Pitfalls
When a cookie doesn't behave as expected, the cause is usually one of these:
- The page is prerendered: reads return nothing and writes are discarded. Add
export const prerender = false. - No path: the cookie is scoped to the current URL path and missing elsewhere. Set
path: '/'. - Delete doesn't match:
delete()needs the samepathanddomainasset(). secure: trueon plain HTTP: browsers refuse to store a secure cookie sent from an insecure origin.localhostis generally treated as secure, but a LAN IP address or custom dev hostname overhttp://isn't. Useimport.meta.env.PRODto switch it on only in production.- Setting cookies too late: set cookies in page frontmatter, endpoints, actions or middleware rather than deep inside child components. Once Astro has started streaming HTML to the browser, the response headers have already been sent.
- Trusting the value: cookies are user input. Validate them, and never store secrets or authorisation decisions in a plain cookie that the server doesn't check.
Open your browser's DevTools, go to Application (Chrome, Edge) or Storage (Firefox), and inspect Cookies to see exactly what the browser stored, with its path, expiry and flags.
FAQ: Cookies in Astro
The page is almost certainly prerendered. Cookies only exist at request time, so add export const prerender = false to the page, or use output server, and make sure an adapter is installed.
Use the cookies property of the endpoint context, for example cookies.get('name')?.value inside your GET or POST handler. It's the same API as Astro.cookies in pages.
Yes. Pass an object to cookies.set and Astro stores it as JSON. Read it back with cookies.get('name')?.json(), wrapped in a try block because the value can be tampered with and json throws on invalid input.
The browser only removes a cookie when the deletion has the same path and domain as the original. Pass the same path and domain options to delete that you used when setting it.
Use cookies for small, non-sensitive values such as a theme or language. Use Astro sessions for larger or sensitive data, which keeps the data on the server and puts only a session ID in the cookie.
Yes, unless the cookie was set with httpOnly true. HttpOnly cookies are sent to the server but hidden from document.cookie, which is what you want for session IDs and other sensitive values.
Conclusion
Astro gives you one cookie API everywhere that runs at request time: Astro.cookies in pages and components, and context.cookies in endpoints, middleware and actions. Use get() with optional chaining and the number(), boolean() and json() helpers to read values, set() with an explicit path, maxAge, httpOnly, secure and sameSite to write them, and delete() with matching options to remove them.
Most cookie bugs come from prerendered pages, a missing path or a delete that doesn't match the original. Keep cookies small, validate them like any other input, and move anything large or sensitive into server-side sessions. With those rules in place, cookies become a reliable building block for preferences, consent banners and authentication.


