Using Astro Server Islands for Personalized Content

Personalisation and caching usually pull in opposite directions: the moment a page shows a user’s name, cart count, or recommendations, you can no longer serve it from a CDN. Astro’s server islands break that trade-off. With server:defer, a component renders out of band — after the cached shell has already been sent — so the page itself stays fully cacheable while the personalised fragment runs fresh on every request. This guide builds on Astro Islands and Client Directives and shows exactly how to wire a per-user server island into an otherwise static page, then verify the cache boundary holds.

Prerequisites


How a Server Island Splits the Cache Boundary

The mechanism is easiest to hold onto as a two-request picture: one cacheable request for the shell, one per-user request for the island.

Astro Server Island Cache Boundary The browser requests the page and receives a cached shell from the CDN containing a fallback placeholder. A second request goes to the server island endpoint, bypassing the cache, and the origin renders personalized HTML from the user's cookie. That HTML replaces the placeholder in the page. Browser 1 page + 1 island CDN cache shell + fallback HTML Cache-Control: s-maxage Origin (per request) /_server-islands/Greeting reads cookie → personalizes Cache-Control: private, no-store Rendered page island swaps into the fallback slot GET / (cache hit) GET island (bypass)

Implementation Steps

Step 1 — Confirm an on-demand adapter is configured

Goal: Give Astro a runtime that can render the server island endpoint per request.

Server islands need on-demand rendering; a purely static build has nowhere to run the deferred component. Ensure an adapter is set and the page can render on demand.

// astro.config.mjs
import { defineConfig } from "astro/config";
import adapter from "@astrojs/node"; // any on-demand adapter works

export default defineConfig({
  // On-demand output lets the /_server-islands/ endpoint execute per request.
  output: "server",
  adapter: adapter({ mode: "standalone" }),
});

Expected output: astro build completes and reports a server entry. If it reports a fully static output, server islands will not be fetched — the fallback would render permanently.


Step 2 — Mark the personalised component with server:defer

Goal: Turn the per-user component into a server island so it renders after, and separately from, the cached page.

---
// src/pages/index.astro — this page can be cached; the island cannot.
import Layout from "../layouts/Layout.astro";
import Greeting from "../components/Greeting.astro";
---

  <h1>Welcome to Acme</h1>

  {/* server:defer renders Greeting out of band. The page HTML ships with the
      fallback in place; Astro fetches the real Greeting per request. */}
  
    {/* slot="fallback" is what lands in the cached HTML until the island resolves. */}
    <p slot="fallback">Welcome back!</p>
  

  <section>{/* …static marketing content, fully cacheable… */}</section>

Expected output: View-source of the page shows the fallback (Welcome back!), not the personalised greeting. The page HTML is identical for every user — which is exactly what makes it cacheable.


Step 3 — Read per-user state inside the island

Goal: Personalise the deferred component from the request’s cookies or headers.

The server island runs per request, so it has access to the real request context even though the surrounding page was cached.

---
// src/components/Greeting.astro — runs on the server, per request, out of band.
// It reads the session cookie that the cached shell never saw.
const session = Astro.cookies.get("session")?.value;
const user = session ? await lookupUser(session) : null;
---
{user ? (
  <p>Welcome back, {user.firstName} — you have {user.unread} new messages.</p>
) : (
  <p>Welcome! <a href="/login">Sign in</a> for a personalised feed.</p>
)}

Expected output: In the browser, the fallback is replaced by the personalised greeting for signed-in users and by the sign-in prompt for anonymous ones — while the page HTML in cache remains the same for both.


Step 4 — Cache the shell, keep the island private

Goal: Serve the page from cache aggressively while ensuring the island response is never cached across users.

Set long-lived shared-cache headers on the page, and make sure the server island endpoint is marked private so a personalised response is never reused for another user.

---
// src/pages/index.astro frontmatter — cache the shell at the edge.
Astro.response.headers.set(
  "Cache-Control",
  "public, s-maxage=3600, stale-while-revalidate=86400"
);
---
---
// src/components/Greeting.astro — never cache a personalised island response.
Astro.response.headers.set("Cache-Control", "private, no-store");
---

Expected output: The document response carries the shared-cache header and hits the CDN on repeat loads; the island request carries private, no-store and always reaches the origin.


One Page, Two Cache Lifetimes A request path shows the document being served from the edge cache with a long time to live, while a marked region issues a separate request that bypasses the cache and is rendered per visitor. The diagram notes what each half may contain: no personal data in the cached shell, no expensive shared work in the per-request fragment. Cache the page, not the person visitor request cookie present, but the document is cacheable edge cache HIT — shell shared by every visitor TTL measured in hours island request — per visitor no-store, reads the session renders greeting and offers BELONGS IN THE CACHED SHELL navigation, copy, product data, prices without customer-specific discounts, everything indexable never a name, a balance, or a Vary on cookie BELONGS IN THE PER-VISITOR ISLAND greeting, saved items, entitlement badges, recommendations keyed on the session never a slow shared query every visitor repeats

Verification

  1. Two requests, two cache behaviours. In DevTools → Network, load the page and confirm exactly two relevant requests: the document (served from cache on repeat loads) and a request to the /_server-islands/ endpoint (always from origin). The document’s Cache-Control should be shared/long-lived; the island’s should be private, no-store.

  2. Shell is user-independent. Load the page as two different users (two cookies) and diff the raw document HTML — not the rendered page. The documents must be byte-identical; only the island responses should differ. Any per-user difference in the document means personalisation has leaked into the cacheable shell.

  3. Fallback renders without JS. Disable JavaScript and load the page. The fallback slot content must be visible — it is part of the static HTML. This confirms the fallback UI is real markup, not a client-rendered placeholder.

  4. No layout shift on swap. Size the fallback to match the resolved island so the swap does not move surrounding content. Record a Performance trace and confirm no Layout Shift entry when the island resolves.


Proving the Split Actually Works Three verification checks. Request the page twice with different session cookies and diff the HTML — only the fragment endpoint should differ. Inspect the cache status header on the document across repeated requests; it must report a hit. Confirm the fragment response carries no-store and no shared cache directive, so an intermediary cannot serve one visitor's fragment to another. Three checks before this goes live 1 · diff the document across two sessions byte-identical documents; only the fragment endpoint returns different content 2 · confirm a cache hit on the document repeat the request; the cache status header must report a hit, not a revalidation 3 · confirm the fragment is uncacheable private, no-store, and no shared-cache directive — this is the check that prevents a leak

Troubleshooting

The fallback shows permanently — the real island never appears

Root cause: The build produced static-only output, so there is no server endpoint to fetch the island from. server:defer requires on-demand rendering.

Fix: Configure an on-demand adapter and set output so the page renders on demand. Rebuild and confirm the build reports a server entry and a /_server-islands/ route.

// astro.config.mjs
export default defineConfig({ output: "server", adapter: adapter() });
Personalised content is cached and shown to the wrong user

Root cause: The server island response is being cached and reused across users, or personalisation was placed in the cacheable page rather than the deferred component.

Fix: Ensure the personalised logic lives inside the server:defer component, and set Cache-Control: private, no-store on that component’s response. Keep the page shell free of any per-user output so it stays safely cacheable.

The island cannot read the session cookie

Root cause: The cookie is scoped so the server island request does not carry it (wrong path/domain), or the value was read in the cached page rather than in the deferred component.

Fix: Read cookies with Astro.cookies.get(...) inside the server:defer component, and ensure the cookie is set with a path/domain that the island endpoint request includes. The island runs per request and has full access to the incoming cookies.

Page jumps when the greeting loads (layout shift)

Root cause: The fallback slot is a different height from the resolved island, so replacing it reflows the page.

Fix: Make the fallback the same shape and size as the personalised content — same number of lines, same container height. Reserve space with min-height if the resolved height is variable, mirroring the skeleton placeholder strategy.


Frequently Asked Questions

What does server:defer do in Astro?

server:defer turns a component into a server island: Astro renders the surrounding page with the component's fallback content, then fetches the real component out of band from a dedicated endpoint and swaps it in. This lets the main page be cached while the deferred component runs per request.

How do server islands differ from client islands in Astro?

A client island (client:load, client:visible) ships JavaScript to hydrate interactivity in the browser. A server island (server:defer) ships no component JavaScript — it renders on the server after the page and injects HTML. Client islands are for interactivity; server islands are for per-request or personalized content on otherwise cacheable pages.

Can I cache a page that contains a server island?

Yes — that is the point. The page shell, including the island's fallback, can be served from cache or a CDN. The server island is fetched separately per request, so personalized content never contaminates the cached HTML and the cache hit rate on the shell stays high.

← Back to Astro Islands and Client Directives