Astro Server Islands: Cache the Page, Not the User

Astro server islands let you mark a single component with server:defer so it renders on the server after the page HTML has already gone out. Everything around it stays static and CDN-cacheable, and the personalised part arrives in a second request. One directive, no client-side data fetching of your own.

The problem is familiar. A site is fully static until someone adds “Hi, Jane” to the header, or a basket count, or a live stock badge. That one component pulls the whole route into on-demand rendering and the CDN stops serving anything.

What Astro server islands actually do

---
import Basket from '../components/Basket.astro';
import BasketSkeleton from '../components/BasketSkeleton.astro';
---
<header>
  <a href="/">Acme</a>
  <Basket server:defer>
    <BasketSkeleton slot="fallback" />
  </Basket>
</header>

At build time Astro omits the component from the page and emits the slot="fallback" content plus a small script in its place. Each deferred component is split into its own route under /_server-islands/<ComponentName>. In the browser, that script requests the route and swaps the returned HTML into the placeholder.

Give the fallback the same dimensions as the real component. Otherwise the swap shifts layout and you have traded a slow page for a jumpy one.

Does the page have to be server-rendered?

No. The page itself can be prerendered. What you need is an adapter, because something has to answer /_server-islands/* at runtime.

That distinction caused real pain in early Astro 5. With output: 'static' and no page anywhere carrying export const prerender = false, the build reported buildOutput: static, the adapter never produced an SSR entrypoint, and the island endpoint 404’d in production (issue #12744). The workaround people passed around was adding a throwaway on-demand page to force a server build. PR #12982 fixed it properly by registering the island endpoint whenever an adapter is present, so output: 'static' with @astrojs/node or @astrojs/cloudflare works now.

One exception survives. An adapter that declares adapterFeatures.buildOutput: 'static' has nothing to serve the endpoint with, and Astro errors at build time if an island is used anyway.

What can you pass as props?

Props cross a network boundary, so they have to serialise. The server islands guide lists plain objects, number, string, Array, Map, Set, RegExp, Date, BigInt, URL, Uint8Array, Uint16Array, Uint32Array and Infinity. Functions and circular references do not survive the trip.

They also go out encrypted in the query string of a GET. That has a ceiling. Past the 2048-byte URL limit browsers enforce, Astro switches to a POST, and a POST is not cacheable by the browser. A fat props object therefore costs you the caching the feature exists to protect. Pass an id, then fetch the record inside the island.

Encryption uses a key generated at build time unless you supply one. During a rolling deploy two versions are live at once, and a page served by the old build sends props the new build cannot decrypt. Generate a key once:

npx astro create-key

Set the printed value as ASTRO_KEY in the environment of every instance.

What behaves differently inside an island

The island renders in its own request, aimed at /_server-islands/Basket rather than at the page. So Astro.url and Astro.request.url both hand you the endpoint URL. Anything branching on the pathname, building a canonical URL, or reading a query parameter gets the wrong answer, with no error to tell you.

The page URL comes from the referrer instead:

---
const referer = Astro.request.headers.get('Referer');
const pageUrl = referer ? new URL(referer) : null;
const isCheckout = pageUrl?.pathname.startsWith('/checkout') ?? false;
---

Cookies behave normally, because the browser sends them to a same-origin endpoint. That is what makes session-based personalisation work at all.

Hold on to one more consequence. Island HTML is not in the initial response, so whatever you defer is missing from the document a crawler or a curl sees, and it cannot be the LCP element. Fine for a basket count. Wrong for the product description.

How do you cache the shell and the island separately?

The prerendered page takes a long Cache-Control at the edge. The island endpoint gets its own header, tuned to the data behind it:

---
// src/components/Basket.astro
Astro.response.headers.set('Cache-Control', 'private, max-age=30');
const basket = await getBasket(Astro.cookies.get('sid')?.value);
---
<span class="basket-count">{basket.items.length}</span>

private keeps shared caches out of it, which is what you want for anything user-specific.

For the routes that genuinely cannot be prerendered, Astro 7 (released 22 June 2026, alongside a Rust rewrite of the .astro compiler and the move to Vite 8, which brings its own upgrade surprises) promoted route caching to stable. Pick a provider, then set policy per route:

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

export default defineConfig({
  adapter: node({ mode: 'standalone' }),
  cache: { provider: memoryCache() },
  routeRules: {
    '/products/[...slug]': { maxAge: 3600, tags: ['products'] },
    '/blog/[...slug]': { maxAge: 300, swr: 60 },
  },
});

Astro.cache.set({ maxAge, swr, tags }) does the same from inside a page, and Astro.cache.invalidate({ tags }) purges by tag. Experimental CDN-backed providers ship with the platform adapters: cacheCloudflare() from @astrojs/cloudflare/cache, cacheVercel() from @astrojs/vercel/cache, and cacheNetlify() from @astrojs/netlify/cache. Where that endpoint actually runs is a latency decision in its own right, which we pulled apart in our Cloudflare deployment comparison.

When a server island is the wrong tool

Three cases, and the first is the one that bites.

If the deferred content sits above the fold and takes up real space, you have pushed your LCP into a second round trip. Measure it. A shell that paints at 200 ms and then reflows at 900 ms can score worse on the metrics that matter for page speed than the honest dynamic page you started with.

If the data is cheap and identical for everyone, prerender it or cache the whole route. An island buys nothing there and costs a request.

And if you find yourself with five of them on one page, you have five requests, five sets of headers, five chances to be slow. Islands are per slow thing, not per component. Group the personalised pieces into one island where they share a data source.

What I would do

Reach for server:defer when one part of an otherwise static page depends on the user, that part is small, below the fold or visually stable, and it is backed by a cookie. Set ASTRO_KEY, pass ids rather than objects, size the fallback properly, and put private, max-age on the endpoint.

If more than about a third of the page is personalised, stop. Render the route on demand and put route caching in front of it. The island model is built for a static page with a dynamic corner, and it stops paying the moment that stops describing your page.

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