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.
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.
Verification
-
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’sCache-Controlshould be shared/long-lived; the island’s should beprivate, no-store. -
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.
-
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.
-
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.
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.
Related
- Astro Islands and Client Directives — the parent guide on Astro’s island model and the
client:*directives. - Configuring client:only vs client:visible in Astro — the client-side directive counterpart to server islands.
- Fallback UI and Skeleton Strategies — sizing fallback slots so deferred islands swap in without layout shift.
← Back to Astro Islands and Client Directives