Astro Sessions: Pick the Driver Before You Deploy

Astro sessions give an on-demand rendered page server-side state behind a single cookie: Astro.session.set('cart', items) in one request, await Astro.session.get('cart') in the next. The browser only ever holds a session ID. Where the data itself lives depends on your adapter, and that default is easy to accept without noticing you have chosen anything.

The API has been stable since Astro 5.7.0 and is unchanged in shape in the current 7.x line. What did change, in Astro 6, is how you configure storage, and that is where older tutorials will send you wrong.

Where does the session data actually live?

Not in the cookie. The sessions guide is explicit: “Session data is stored server-side with only a session ID cookie sent to the client.” The cookie defaults to { name: "astro-session", sameSite: "lax", httpOnly: true, secure: true }, and you can override any of those through session.cookie in astro.config.mjs, or pass a plain string to rename it.

The server side is a driver. In Astro 5 you named one with a string (driver: 'redis') and fed it a separate options object. The Astro 6 upgrade guide deprecates that shape because it was “limited and inconsistent with other parts of the Astro config”, and replaces it with functions exported from astro/config:

// astro.config.mjs
import { defineConfig, sessionDrivers } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),
  session: {
    driver: sessionDrivers.redis({
      url: process.env.REDIS_URL,
    }),
    cookie: {
      name: 'sid',
      secure: true,
    },
    ttl: 60 * 60 * 24 * 7,
  },
});

session.options still works in 7.x but is marked deprecated and will go in a future major. If your config has it, move the keys into the driver call now.

The functions on sessionDrivers wrap the unstorage driver set one for one. In [email protected] the typed export lists fs, fsLite, lruCache, redis, upstash, cloudflareKVBinding, cloudflareKVHttp, cloudflareR2Binding, netlifyBlobs, vercelKV, vercelBlob, vercelRuntimeCache, db0, mongodb, planetscale, s3, denoKV, http and a handful of Azure drivers. If you need something else, the session driver reference shows the custom shape: a module with a default export returning an object with setItem, getItem and removeItem, pointed at through { entrypoint, config }. The config object has to be serialisable, because Astro hands it to the driver at runtime, not at build.

Which driver does each adapter pick for you?

The Node, Cloudflare and Netlify adapters each configure a driver when you leave session.driver unset. Each default suits the platform’s simplest deployment. Two of them have a failure case worth knowing before you ship.

@astrojs/node (11.1.7 at the time of writing) logs “Enabling sessions with filesystem storage” and uses sessionDrivers.fsLite with base set to a sessions folder inside Astro’s cacheDir, which defaults to ./node_modules/.astro. That is fine on one long-lived box. It breaks the moment you run two instances behind a load balancer, because each process has its own folder, and it loses every session on a redeploy that starts from a fresh node_modules. Containers on Fly, Railway or ECS fall into both traps. Point it at Redis or Upstash before the first real user logs in.

@astrojs/cloudflare wires up Workers KV. The adapter guide says: “By default, the KV binding is named SESSION”, and Wrangler can provision the namespace on deploy so there is no manual setup. If you already have a binding under another name, set sessionKVBindingName on the adapter and declare it under kv_namespaces in wrangler.jsonc. KV is eventually consistent across regions, which is acceptable for a cart and not acceptable for a one-time token; our comparison of KV, D1 and Durable Objects on Cloudflare goes through when that matters.

Netlify gets a default too. Vercel is not on that list, which is why the docs example for Vercel reaches for sessionDrivers.lruCache({ max: 800 }), an in-memory cache that is per instance and dies with it. Treat that as a development shortcut, not a production store.

Whatever the adapter chooses, Astro.session is undefined on a prerendered route, when no storage is configured, or when you have set session: false. That last option arrived in Astro 7.2.0 and strips the session runtime from the SSR bundle for sites that never use it. In a hybrid project, either mark the route export const prerender = false or write Astro.session?.get(...) and accept that the result may be undefined.

How do you read and write a session in a page, an action and middleware?

The object is the same everywhere: Astro.session in a component, context.session in an endpoint, an action handler or middleware. get returns a promise; set is synchronous in the type signature and the write is flushed to the driver at the end of the request. Awaiting set is harmless and matches the official examples.

// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';

type Cart = { productId: string; qty: number }[];

export const server = {
  addToCart: defineAction({
    input: z.object({ productId: z.string(), qty: z.number().int().positive() }),
    handler: async (input, context) => {
      const cart: Cart = (await context.session?.get('cart')) ?? [];
      const line = cart.find((l) => l.productId === input.productId);
      if (line) {
        line.qty += input.qty;
      } else {
        cart.push({ productId: input.productId, qty: input.qty });
      }
      await context.session?.set('cart', cart);
      return cart;
    },
  }),
};
---
// src/components/CartButton.astro
export const prerender = false;
const cart = (await Astro.session?.get('cart')) ?? [];
const count = cart.reduce((n: number, l: { qty: number }) => n + l.qty, 0);
---
<a href="/checkout">Basket ({count})</a>

Values go through devalue, the same serialiser Astro uses for content collections and actions, so Date, Map, Set, URL, arrays and plain objects round-trip. Class instances do not. If you store a Money object with methods on it, you get a plain object back and a confusing is not a function on the next request. Store the fields, rebuild the object.

Middleware works with one exception: the docs state “Sessions are not supported in edge middleware”, which matters if you deploy Vercel’s edge middleware option. Node middleware is fine, and is the right place for a login check that reads context.session?.get('userId') and redirects.

If you already use actions for forms, the session slots in beside the validation flow we described in Astro Actions form validation: validate with zod, mutate the session in the handler, re-render the page with the new state.

What do ttl, regenerate and destroy do?

ttl is a number of seconds and it is per value, not per session. The configuration reference defaults it to Infinity and says expired values are only deleted when something tries to read them. Two consequences. First, a set with no ttl and no top-level session.ttl never expires, so a Redis or KV store fills with abandoned sessions until you give it one. Second, because deletion is lazy, do not expect the store’s key count to drop on its own. You can also pass { ttl } as the third argument to set for a single value, which is how you give a flash message a one-hour life while the cart keeps the weekly default from the config.

regenerate() issues a new session ID and keeps the data. The API reference is direct about when: “Call this when a user logs in or escalates their privileges, to prevent session fixation attacks.” A fixation attack plants a known session ID in the victim’s browser before they log in; rotating the ID at that boundary makes the planted one worthless.

destroy() deletes the cookie and the stored object. Call it on logout. Setting userId to undefined and leaving the rest is not the same thing, because the cart and anything else keyed on that session survive.

load(id) is the escape hatch for a client that cannot hold a cookie, such as a native app sending the session ID in a header. Normal web flows never need it.

When would you skip sessions altogether?

Skip them for anything that only needs to survive a page load within one tab; sessionStorage in the browser is cheaper and never touches your store. Skip them if the whole site is prerendered, and set session: false so the runtime is not shipped. Skip them for a value that must read back correctly the instant it is written, such as a one-time token, unless your driver guarantees that: KV does not, and a single-region Redis does at the cost of a round trip on every request that touches the session.

Use them for carts, multi-step forms, flash messages, and the userId that proves someone is logged in. Set a ttl on the first day. Pick the driver when you pick the adapter, and if the adapter is Node and there will ever be more than one process, that driver is Redis or Upstash, not the filesystem.

Whoooop builds Astro sites with server-rendered carts and account areas, usually on Cloudflare or Node behind a CDN, and the storage decision above is one of the first things we settle on a project. If you are adding server state to a static Astro site, our ecommerce web development work covers exactly this ground.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch