Server Islands
Most of a page is the same for everyone — the header, the article body, the footer — and can be cached aggressively or served as static HTML. But a few fragments are personal: a logged-in avatar, a cart count, an A/B variant. Server islands let you carve those dynamic fragments out of an otherwise cacheable page. Mark a component with server:defer and Astro renders the rest immediately, streams a placeholder for the island, then fetches the island’s real server-rendered HTML in a separate request and slots it in. You keep a fast, cacheable shell and fresh, server-personalized content — without shipping a client framework to do it.
The problem server islands solve
Traditionally you had two bad choices for a page that is mostly static but has one personalized corner. Either you render the whole page per-request (so it can never be cached on a CDN), or you make the dynamic part a client island and fetch the data from the browser (more JS, layout shift, a loading flash). Server islands give you a third option: cache the shell at the edge, and render the personal bit on the server after the shell is already on its way to the user.
The dynamic part still runs on the server — it can read cookies, query a database, or call a private API — but it no longer blocks or pollutes the cacheability of everything around it.
Using server:defer
Add the server:defer directive to any .astro component. The component is excluded from the initial render and fetched separately. It does not need a client:* directive and ships no client framework.
---
// src/pages/index.astro
import Avatar from '../components/Avatar.astro';
import Article from '../components/Article.astro';
---
<html lang="en">
<body>
<Article /> <!-- static, cacheable -->
<Avatar server:defer /> <!-- deferred server island -->
</body>
</html>
The Avatar.astro component reads request-specific data as usual:
---
// src/components/Avatar.astro
const user = await getUser(Astro.request);
---
<img src={user.avatarUrl} alt={user.name} class="avatar" />
When the page loads, Astro sends the article instantly. The browser then makes a second request to a route Astro generated for the island, runs getUser, and swaps the result into place — all before any meaningful paint of the island.
Note: Server islands require an SSR-capable deployment. Add an adapter (
@astrojs/node,@astrojs/vercel,@astrojs/cloudflare, etc.) — the surrounding page can still be statically prerendered while the island is rendered on demand.
Fallback content
While the island is being fetched, you can show a placeholder using the named fallback slot. This avoids a blank gap and lets you reserve layout space to prevent shift.
---
import Avatar from '../components/Avatar.astro';
---
<Avatar server:defer>
<div slot="fallback" class="avatar avatar--skeleton">Loading…</div>
</Avatar>
The fallback is part of the cacheable shell, so it appears immediately. The moment the island’s HTML arrives, Astro replaces the fallback with the real content. If you provide no fallback, the island simply renders nothing until it loads.
Server islands vs. client islands
Both defer work, but they defer different work in different places.
| Aspect | Server island (server:defer) | Client island (client:*) |
|---|---|---|
| Where it renders | On the server, after the shell | In the browser |
| Ships JS framework | No | Yes |
| Reads cookies / DB directly | Yes | No (needs an API) |
| Goal | Keep shell cacheable | Add interactivity |
| Output | Server-rendered HTML | Hydrated, interactive UI |
A useful rule: reach for server islands when the dynamic part is data that must stay on the server (auth, personalization, fresh prices). Reach for client islands when the dynamic part is behavior the user interacts with.
Caching the shell
Because the personalized bits are pulled out, the surrounding page can be cached. Set cache headers on the static shell and let the island carry the dynamic cost.
---
// src/pages/dashboard.astro
Astro.response.headers.set(
'Cache-Control',
'public, max-age=0, s-maxage=600' // CDN caches the shell for 10 min
);
---
Output:
GET /dashboard → 200 (HIT from CDN, cached shell)
GET /_server-islands/... → 200 (MISS, rendered per request)
The shell is served from the CDN edge, while only the small island request reaches your origin.
Gotcha: Props passed to a server island are encrypted and sent to the island’s fetch endpoint, so keep them small and serializable. Don’t pass large payloads or secrets you wouldn’t want round-tripping — fetch heavy or sensitive data inside the island instead.
Best Practices
- Use server islands for per-request data (auth state, cart, personalization) on pages you otherwise want cached.
- Always provide a
fallbackslot sized like the final content to prevent layout shift. - Add an SSR adapter; prerender the shell with
export const prerender = truewhere possible and let the island stay dynamic. - Keep deferred props small and serializable — fetch large or secret data inside the island rather than passing it in.
- Prefer a server island over a client island when you need server-only data and no interactivity, to avoid shipping JS.
- Set
Cache-Control(e.g.s-maxage) on the shell so the CDN absorbs traffic while only islands hit the origin.