Server Islands in Astro
Static pages have one awkward requirement that keeps coming up: a small part of an otherwise static page that must be different per request. A signed-in reader's avatar in the header. A product's current stock level on a page whose description changes twice a year. A price that depends on the visitor's region. The traditional choices were all expensive: render the whole page on demand and lose static caching, or fetch JSON from a client-side component and ship a framework to render it.
Astro's server islands, stable since Astro 5, offer a third option. The page is prerendered and cached like any static page, with a fallback where the dynamic part will go. After load, a tiny script requests that part's HTML from an on-demand endpoint and swaps it in. This guide adds two server islands to a static product documentation site — account state in the header and live API status on the status page — measures their cost and caching, and covers fallbacks that do not shift the layout. It is part of JavaScript Hydration & Partial Rendering.
Prerequisites
- Astro 5 with an adapter that supports on-demand rendering (this site uses
@astrojs/cloudflare). - Pages remaining prerendered:
export const prerender = trueoroutput: 'static'with the adapter for islands. - An identity or data source the island will read (a session cookie, an API).
How a Server Island Works
A component is marked with server:defer. At build time, Astro renders the page without it, inserting the fallback slot content and a small script. At request time, the static page is served from the CDN. The script then requests /_server-islands/<Component>?… with the island's props encrypted in the URL, the adapter renders the component on demand, and the returned HTML replaces the fallback.
---
// src/components/Header.astro
import AccountMenu from './AccountMenu.astro';
---
<header>
<a href="/">Docs</a>
<nav>…</nav>
<AccountMenu server:defer>
<a slot="fallback" href="/login/" class="account-link">Sign in</a>
</AccountMenu>
</header>
---
// src/components/AccountMenu.astro — runs on demand
const session = await getSession(Astro.cookies.get('session')?.value);
---
{session
? <a href="/account/" class="account-link"><img src={session.avatar} alt="" width="24" height="24" />{session.firstName}</a>
: <a href="/login/" class="account-link">Sign in</a>}
The page, including every other part of the header, stays cacheable at the CDN for all readers. Only the island's request carries the cookie and runs code.
Island Two: Live Status
The status page is static documentation about the service, with a live block showing current API health. The block reads from the monitoring API with a secret token, which must not be shipped to the browser — a good fit for a server island. The island's response is the same for everyone, so it can be cached briefly at the edge:
---
// src/components/LiveStatus.astro
Astro.response.headers.set('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=60');
const status = await fetch(`${import.meta.env.STATUS_API}/summary`, {
headers: { Authorization: `Bearer ${import.meta.env.STATUS_TOKEN}` }
}).then((r) => r.json());
---
<section class="live-status" aria-live="polite">
{status.components.map((c) => <p><span class={`dot dot-${c.state}`} />{c.name}: {c.label}</p>)}
</section>
With a 30-second shared cache, the monitoring API received at most two requests a minute per edge region regardless of traffic, and readers saw status no more than about a minute old. The account island, by contrast, sends Cache-Control: private, no-store, because its content is per reader.
Fallbacks That Do Not Shift
The fallback renders in the static HTML and is replaced when the island arrives. If they differ in size, everything below moves. For the account menu, the fallback "Sign in" link and the signed-in state (avatar plus first name) were given the same fixed height and a min-width on the container, so the swap changed contents but not layout. For the status block, the fallback is a skeleton with the same number of rows as the service has components, generated at build time from the component list, with each row at its final height. Field CLS on both templates stayed at 0.01, measured as described in CLS from Client-Side Rendered Components.
What It Costs
Server islands are cheaper than rendering the whole page on demand, but not free:
| Approach | Page cacheable? | Extra request | JS shipped | Compute per view |
|---|---|---|---|---|
| Whole page on demand | no | — | 0 | full page render (~95 ms) |
| Client island fetching JSON | yes | 1 (JSON) | React + component, 48 KB | none on server |
| Server island | yes | 1 (HTML) | loader, 0.6 KB | island render (~28 ms) |
| Static only (no personalization) | yes | — | 0 | none |
For the header, the server island replaced a React client island that fetched /api/me and rendered the menu. JavaScript on every docs page fell by 48 KB, and the account menu appeared about 150 ms sooner on mobile, because the island HTML needed no hydration. Worker invocations rose by one per page view for signed-in readers, well within the free tier for this site's traffic.
Errors and Timeouts
An island request can fail — the session store is slow, the monitoring API is down, the Worker hits an exception. When that happens, the fallback simply stays on the page, which is why the fallback must be a complete, correct state and not a spinner. For the account menu, the fallback "Sign in" link is exactly what a signed-out reader sees, and a signed-in reader who sees it can still reach their account in one click. For the status block, the skeleton fallback includes a line of text, "Live status unavailable — see status.example.com", revealed with CSS after four seconds if the island has not replaced it. Inside the island, calls to upstream services have their own short timeouts (800 ms for the status API) and render a degraded state instead of throwing, so a slow dependency produces a clear message rather than a hanging request. Island errors are logged with the Worker's observability tooling and counted in the same dashboard as the site's 404s.
When Not to Use Them
Server islands add a request after page load, so anything above the fold that must be correct in the first frame — the main heading, the LCP image, the primary call to action — should be static. Content that is the same for everyone and changes rarely belongs in the build, refreshed by a rebuild, rather than in an island. And content that needs rich client interactivity after it loads still needs a client island; a server island can render the initial HTML and include a small client component inside it if necessary. On this site, a proposal to make pricing a server island was rejected in favour of rebuilding on price changes, which happen a few times a year.
Measured Impact
| Measure | React client island + JSON API | Server islands |
|---|---|---|
| JS on a docs page (brotli) | 92 KB | 44 KB |
| Account menu visible, mobile lab | 1.48 s | 1.33 s |
| INP p75 (field) | 140 ms | 96 ms |
| CDN cache hit ratio for HTML | 97% | 97% |
| Monitoring API calls per day | ~41,000 (client polling) | ~5,800 (edge cache) |
| CLS p75 | 0.02 | 0.01 |
Pitfalls & Rollback
- Mismatched fallback size. The swap shifts content; match dimensions.
- Caching per-user islands. Always
private, no-storefor anything that depends on cookies. - Critical content in an island. It appears after load; keep LCP content static.
- Large props. Props are encrypted into the island URL; pass IDs, not objects.
- Rollback: removing
server:deferrenders the component at build time with its build-time data, or the previous client island can be restored; the rest of the page is unchanged.
Conclusion
Server islands let a static page keep its CDN caching while one small part is rendered per request on the server, with no framework shipped to the browser. Use them for per-reader state and for shared live data that needs secrets, give each island its own caching policy and a same-size fallback, and keep anything critical to the first frame static. On this site they replaced a React client island, halved JavaScript on docs pages and cut calls to the monitoring API by 86%.
FAQ
What are server islands?
Components marked with server:defer in Astro. The page itself is prerendered and cached as static HTML with a fallback in place of the island. After the page loads, the browser requests the island's HTML from an on-demand endpoint and swaps it in, so one small part of the page can be dynamic per request.
Do server islands need a server?
Yes, an adapter that can run on-demand routes, such as Cloudflare, Netlify, Vercel or Node. The rest of the site can stay fully prerendered. Only requests for island content run code.
How are server islands different from client islands?
Client islands ship JavaScript and render in the browser. Server islands render on the server and ship HTML, with only a tiny loader script. They suit dynamic content that needs server data or secrets, such as a signed-in user's name, stock levels or a personalised price, without shipping a framework.
Do server islands cause layout shift?
They can if the fallback is a different size from the final content. Give the fallback the same dimensions as the rendered island, or reserve space with CSS, so the swap does not move anything.
Related
- Parent: JavaScript Hydration & Partial Rendering — islands and hydration strategy.
- Astro Islands vs Full Hydration Performance — client islands measured.
- Proxying Third-Party APIs from an Edge Function — dynamic data from an edge route instead.
- Deploying Astro to Cloudflare Workers Static Assets — the adapter setup.
- Protecting a Static Site Behind Authentication — sessions for the account island.