Skip to content

Fix: Astro Server Islands Not Working — server:defer, Fallback, Cookies, Caching, and Hydration

FixDevs ·

Quick Answer

How to fix Astro 5 server islands — server:defer directive ignored, fallback slot missing, cookies/headers in deferred component, output config mismatch, dynamic island fetch URL, and caching the static shell.

The Error

You add server:defer to a component and nothing happens — it renders inline as if the directive didn’t exist:

---
import UserProfile from "../components/UserProfile.astro";
---

<UserProfile server:defer />

Or it tries to defer but errors at build time:

[Astro] Server islands require `output: 'server'` or `output: 'hybrid'` 
to be set in your astro.config.mjs

Or the fallback slot doesn’t show during the defer wait:

<UserProfile server:defer>
  <p slot="fallback">Loading...</p>
</UserProfile>

<!-- Output: nothing renders, then UserProfile pops in. -->

Or the deferred component can’t read cookies:

---
// Inside the deferred component:
const session = Astro.cookies.get("session")?.value;
console.log(session); // undefined, even though the cookie is set.
---

Why This Happens

Server Islands (Astro 5) split a page into two parts:

  • Static shell — pre-rendered HTML, cacheable, fast.
  • Deferred islands — rendered on-demand at request time, inserted into the shell via a <script> that fetches them.

The static shell can sit on a CDN; the dynamic parts run only when a user actually requests the page. This is the high-performance answer to “I need this page mostly static but also user-specific.”

Three sources of pain:

  • server:defer requires SSR. Pure static (output: 'static') sites have no server to run the deferred fetch against. Use output: 'server' or per-page prerender = false.
  • Component props must be JSON-serializable. Astro encrypts and sends props to the deferred endpoint. Functions, class instances, Maps — all fail.
  • The fallback slot uses the name="fallback" convention. Other slot names don’t render during defer.
  • Cookie/header access works but is different in shell vs island contexts. The shell renders at build/CDN edge; the island renders per request and has full request context.

Fix 1: Configure SSR Output

In astro.config.mjs:

import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  output: "server",  // or "hybrid"
  adapter: node({ mode: "standalone" }),
});

Pick the adapter for your deploy target:

# Cloudflare Pages / Workers:
npm install @astrojs/cloudflare

# Vercel:
npm install @astrojs/vercel

# Netlify:
npm install @astrojs/netlify

# Node:
npm install @astrojs/node

For mostly-static sites where only some pages need SSR:

export default defineConfig({
  output: "server",
  adapter: ...,
});
---
// In each page that should be static:
export const prerender = true;
---

Pages with prerender = true are pre-built; others render at request time. Server islands work on pre-rendered pages — the shell is static, the islands are dynamic.

Pro Tip: For maximum cacheability, prerender every page and use server islands only for the dynamic bits (user menu, cart count, recently viewed). The static HTML can sit on the edge CDN; only the deferred fetches hit your server.

Fix 2: Use the fallback Slot

The slot name fallback is the convention next to server:defer:

---
import UserMenu from "../components/UserMenu.astro";
---

<UserMenu server:defer>
  <div slot="fallback" class="h-8 w-32 bg-gray-200 animate-pulse rounded" />
</UserMenu>

The fallback renders inline in the static shell. When the deferred fetch completes, Astro swaps the fallback for the real component.

For inline default content (no extra markup):

<UserMenu server:defer>
  <span slot="fallback">Signed out</span>
</UserMenu>

Common Mistake: Forgetting slot="fallback" and putting children directly inside the component:

<!-- DOESN'T act as fallback: -->
<UserMenu server:defer>
  Loading...
</UserMenu>

<!-- Correct: -->
<UserMenu server:defer>
  <span slot="fallback">Loading...</span>
</UserMenu>

Fix 3: Pass Only Serializable Props

Props are sent from the shell to the deferred endpoint as encrypted JSON. They must round-trip through JSON.stringify/parse:

<!-- Works: primitives, plain objects, arrays -->
<UserProfile server:defer userId={42} initials="JD" />

<!-- Doesn't work: functions, Date, Map, class instances -->
<UserProfile server:defer onClick={() => {}} createdAt={new Date()} />

For dates, send strings:

<UserProfile server:defer createdAt={new Date().toISOString()} />
---
// In UserProfile.astro:
interface Props {
  createdAt: string;
}
const { createdAt } = Astro.props;
const date = new Date(createdAt);
---

For complex objects, send IDs and fetch inside the component:

<!-- Send only the user ID: -->
<UserProfile server:defer userId={user.id} />
---
// UserProfile.astro fetches its own data:
const { userId } = Astro.props;
const user = await db.users.findById(userId);
---
<h2>{user.name}</h2>

Note: Sending IDs is also better for cache hit rates — the URL of the deferred fetch is shorter and more uniform across users.

Fix 4: Read Cookies and Headers Inside the Island

Inside a deferred component, Astro.request, Astro.cookies, and Astro.url all work as in any SSR component:

---
// UserMenu.astro
const session = Astro.cookies.get("session")?.value;
if (!session) return <SignedOutMenu />;

const user = await getUserBySession(session);
---

<div>Hi, {user.name}</div>

The deferred fetch carries the original request’s cookies. The shell renders without cookie context (it’s static); the island sees the full request.

For setting cookies from a deferred island:

---
Astro.cookies.set("last_visit", new Date().toISOString(), {
  path: "/",
  maxAge: 60 * 60 * 24 * 30,
});
---

Set-Cookie headers from the island propagate back through the deferred response. The browser stores them as if the cookie came from the original page.

Fix 5: Cache the Shell Aggressively

The whole point of server islands is shipping a cacheable shell. Set cache headers on the static page:

---
// In your page or layout:
Astro.response.headers.set(
  "Cache-Control",
  "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"
);
---

For pages with prerender = true, the HTML is already cacheable — set CDN-level headers via your deploy platform (Cloudflare Cache Rules, Vercel vercel.json, Netlify netlify.toml).

The deferred fetches are uncached by default (they’re per-user). To cache deferred islands (rare — only for content that’s actually shared across users):

---
// Inside the deferred component:
Astro.response.headers.set("Cache-Control", "public, max-age=60");
---

Pro Tip: Most apps want long shell TTL + zero deferred-island TTL. The shell is the same for everyone; the island is per-request.

Fix 6: Test Server Islands Locally

In dev, astro dev serves SSR by default, so islands work the same as in production. To explicitly test the static-shell + island fetch flow:

npm run build
npm run preview
# Opens the built site against the adapter's preview server.

preview is closer to production. If dev works but preview fails, the issue is usually the adapter config or environment-specific (Cloudflare’s nodejs_compat, Vercel’s runtime mismatch).

Common Mistake: Testing with browser cache disabled, missing the fact that the shell is supposed to be cached. Open DevTools → Network and watch the deferred fetches as separate XHR requests after the page loads.

Fix 7: Hydration of Client Components Inside Islands

Server islands can contain client components with the usual client:* directives:

---
// UserMenu.astro (server-deferred)
import LogoutButton from "./LogoutButton.tsx";
---

<div>
  <span>Signed in as {user.name}</span>
  <LogoutButton client:load />
</div>

The deferred island fetch returns HTML for the static parts and the JS for client-hydrated components. Astro inserts them into the page after the fetch.

Note: client:load inside an island still fetches the framework runtime separately. For high-frequency islands, prefer client:visible to avoid loading client JS until the island is actually on screen.

Fix 8: Multiple Islands on One Page

A page can have many server:defer islands. They all fire in parallel from the browser:

<header>
  <UserMenu server:defer />
  <CartCount server:defer />
</header>

<main>
  <RecentlyViewed server:defer />
  <PersonalizedRecs server:defer />
</main>

Each island is its own fetch. The browser batches them, but each is independent — one slow island doesn’t block others.

For aggressive performance, batch related data into one island:

<!-- Worse: 3 fetches -->
<UserName server:defer />
<UserAvatar server:defer />
<UserSettings server:defer />

<!-- Better: 1 fetch carrying all user data -->
<UserHeader server:defer />

Still Not Working?

A few less-obvious failures:

  • Islands work locally, fail on Cloudflare Pages. Set compatibility_flags = ["nodejs_compat"] in wrangler.toml and verify the adapter version supports your deploy target.
  • Encrypted props blow up on size. Astro signs props with a server-side secret. Very large prop objects hit URL limits. Trim props — send IDs, fetch in the island.
  • Astro.url.pathname is wrong inside the island. That’s the deferred endpoint URL, not the original page URL. Read the original from Astro.url.searchParams.get("page") or pass as a prop.
  • prerender = false page has slow first byte. Server islands help only when the shell is cached. If every visit hits SSR for the shell and fetches islands, you’ve doubled the work. Make the shell static (prerender = true).
  • Adapter doesn’t support server:defer. Check the adapter’s release notes. Older versions don’t ship the island runtime. Update.
  • Build error: Could not encrypt props. A non-serializable prop sneaked in (often undefined in a typed-string field). Strip undefined keys before passing.
  • Island fetch returns 500. Look at server logs. The island runs full SSR; any error there returns 500 and the fallback stays visible.
  • Search engines see fallback content only. Search bots typically don’t execute the deferred fetch. If your indexable content lives inside a deferred island, move it back to the shell. Server islands are for personalization, not primary content.

For related Astro and SSR issues, see Astro DB not working, Astro actions not working, Starlight not working, and Next.js app router fetch cache.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles