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 = true or output: '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.

Request sequence for a prerendered page with a server island The browser requests a documentation page and the CDN serves the prerendered HTML from cache, including the Sign in fallback. After the page renders, the island script requests the AccountMenu island from the on-demand endpoint with the session cookie. The Worker renders the island in about 28 milliseconds and returns 1.1 kilobytes of HTML, which replaces the fallback. A static page with one server island Browser reader CDN cache static HTML Island endpoint Worker on demand GET /docs/guide/install/ cached HTML with fallback (hit) GET /_server-islands/AccountMenu + cookie 1.1 KB island HTML (~28 ms) swap fallback for island
The page itself is never rendered per request; only the 1.1 KB island is.

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.

Cache policies for a per-user island and a shared island Two panels. The account menu island depends on the reader's session cookie, sends Cache-Control private no-store, runs on every page view that loads it and has a fallback of the same size as the signed-in state. The live status island is the same for everyone, sends public s-maxage 30 with stale-while-revalidate, runs at most twice a minute per region and has a skeleton fallback with one row per component. Two islands, two caching policies AccountMenu (per reader) reads the session cookie private, no-store runs on each page view same-size fallback median 28 ms, 1.1 KB LiveStatus (shared) reads a secret-token API s-maxage=30, swr=60 about 2 runs/min per region row skeleton fallback median 41 ms on miss, 0.9 KB
The page around both islands is cached for everyone; each island chooses its own policy.

What It Costs

Server islands are cheaper than rendering the whole page on demand, but not free:

ApproachPage cacheable?Extra requestJS shippedCompute per view
Whole page on demandno—0full page render (~95 ms)
Client island fetching JSONyes1 (JSON)React + component, 48 KBnone on server
Server islandyes1 (HTML)loader, 0.6 KBisland render (~28 ms)
Static only (no personalization)yes—0none

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.

Account menu JavaScript as a client island and as a server island Horizontal bars of brotli-compressed JavaScript loaded on every docs page for the account menu. As a React client island fetching JSON, 48 kilobytes. As a server island, only Astro's island loader of 0.6 kilobytes. The signed-in menu also appeared about 150 milliseconds sooner on a throttled mobile load. JavaScript for the header account menu React client island 48 KB Server island loader 0.6 KB
Lighthouse mobile profile with a signed-in session cookie, median of five runs.

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

MeasureReact client island + JSON APIServer islands
JS on a docs page (brotli)92 KB44 KB
Account menu visible, mobile lab1.48 s1.33 s
INP p75 (field)140 ms96 ms
CDN cache hit ratio for HTML97%97%
Monitoring API calls per day~41,000 (client polling)~5,800 (edge cache)
CLS p750.020.01

Pitfalls & Rollback

  • Mismatched fallback size. The swap shifts content; match dimensions.
  • Caching per-user islands. Always private, no-store for 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:defer renders 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.

Filed underHow-tosAstro