LabHub

Blog

Astro 5 Deep Dive: Server Islands, Content Layer, and the New Standard for Content Sites

한국어English日本語

"Let's redefine 'static.' Cache everything that can be cached, and only re-render the bits that genuinely have to differ per person — late, on the server, in place." — A one-line summary of the Astro 5 Server Islands design.

Prologue — The era of "just a static site generator" is over

Astro in 2022 was a static site generator for Markdown blogs. Astro 5, released on December 3, 2024, wears the same name but is a different thing. It is the de facto standard for content-driven sites — and that's not hyperbole.

This post wants to answer a small number of questions clearly:

This is not an ad. The things Astro does well and the things it doesn't carry equal weight. Make your decision at the final table.


1. Islands architecture recap — the one thing Astro did differently from day one

A quick refresher. While other frameworks went SPA → SSR → RSC, Astro took a different road from the start.

1.1 "Every page is HTML first"

The default output is HTML. JavaScript is added explicitly, per component that needs it. That policy has a name: Islands Architecture.

A page is not a single React tree. It's a sea of static HTML with interactive "islands" floating in it. Each island has its own bundle and its own hydration timing. The result is that the average content page ships dramatically less JavaScript to the client than competing frameworks.

1.2 Client directives

You make an island by adding one directive next to the component.

---
import Counter from '../components/Counter.tsx'
import LikeButton from '../components/LikeButton.svelte'
import SearchBox from '../components/SearchBox.vue'
---

<h1>Text outside an island is static HTML</h1>

<Counter client:load />
<LikeButton client:visible />
<SearchBox client:idle />

That one line is the difference between "4 KB of JS on this page" and "300 KB of JS on this page."

1.3 Framework-agnostic

You can mix React, Svelte, Vue, Solid, Preact, and Lit on the same page. Few teams use this aggressively in production, but the meaningful payoff is incremental absorption of legacy widgets. Design system in Svelte, interactive demo in React — that split feels natural.


2. The big picture in Astro 5 — six headline changes

If you had to fit Astro 5 onto one screen, it would look like this.

AreaAstro 4Astro 5
Content modelContent Collections (file-based)Content Layer API (any source)
Dynamic pagesWhole-page SSR or staticServer Islands (server islands on static)
BundlerVite 5Vite 6, with Vite 7 joining the 6.x line
Forms / mutationsHand-rolled handlersAstro Actions (type-safe)
RoutingStatic + partial SSRPer-route prerender refined
i18nExperimentalStable + fallback / domain routing

One sentence: "It kept static-first and absorbed parts of full-stack." Astro is arriving at the same place as Next.js from the opposite direction. Next.js started in client React and used RSC to redraw the static/server boundary; Astro started in static HTML and used Server Islands to re-admit the server.


3. Server Islands — personalization without breaking static-first

Server Islands are the face of Astro 5. In one sentence:

"Cache the page statically on the CDN, render only the small, per-user bits late on the server, and have the client slot them in afterward."

3.1 The problem — "99% cacheable, 1% not"

Picture a typical content page. Header, body, sidebar, footer — nearly every pixel is the same for every visitor. Only a small corner ("Logged in," "Cart (3)," "Recommended for you") differs per user.

The old options:

  1. Full-page SSR — re-render 99% for the sake of 1%. Cache is useless.
  2. Fully static + client fetch — JS load → fetch → render → layout shift. Core Web Vitals suffer.
  3. ESI / Edge Side Includes — nice in theory, CDN-coupled, hell to debug.

Server Islands is the fourth path.

3.2 Usage — one server:defer

---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro'
import Avatar from '../components/Avatar.astro'
import AvatarFallback from '../components/AvatarFallback.astro'
---

<Layout>
  <h1>Today's picks</h1>
  <p>This body is built at build time and cached on the CDN.</p>

  <Avatar server:defer>
    <AvatarFallback slot="fallback" />
  </Avatar>
</Layout>
---
// src/components/Avatar.astro
import { getUserFromCookie } from '../lib/auth'
const user = await getUserFromCookie(Astro.request)
---

{user ? (
  <a href="/me"><img src={user.avatar} alt={user.name} /></a>
) : (
  <a href="/login">Sign in</a>
)}

A component with server:defer:

  1. Is not rendered at build time. The slot="fallback" takes its place.
  2. The page is cached as static HTML on the CDN. Roughly 0.5 KB of script plus the placeholder.
  3. The browser fetches the real HTML from a dedicated endpoint (/_server-islands/Avatar).
  4. When it arrives, the placeholder is swapped for the real HTML.

The key idea — the body is cached; only the island is personalized. You get 100% CDN utilization with real personalization.

3.3 What GA added

During the beta these landed and rolled into 5.0 GA:

3.4 When to use, and when not

Use when:

Do NOT use when:


4. Content Layer API — generalizing "collections" to "any source"

The second big change is the Content Layer. In Astro 4, Content Collections treated Markdown / MDX on the filesystem as first-class. Astro 5 abstracts one level higher so that the loader is the collection.

4.1 The model — Loader + Schema

// src/content.config.ts
import { defineCollection, z } from 'astro:content'
import { glob } from 'astro/loaders'

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    pubDate: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
})

export const collections = { blog }

Besides glob, there's an official file loader; beyond those, the world is yours.

4.2 Custom loaders — HTTP, DB, CMS

This is the interesting part. Any data source becomes a collection.

// src/loaders/notion.ts
import type { Loader, LoaderContext } from 'astro/loaders'

export function notionLoader(opts: { databaseId: string }): Loader {
  return {
    name: 'notion-loader',
    async load({ store, parseData, meta, generateDigest }: LoaderContext) {
      const lastSynced = meta.get('last-synced')
      const pages = await fetchNotionPages(opts.databaseId, { since: lastSynced })

      for (const p of pages) {
        const data = await parseData({
          id: p.id,
          data: {
            title: p.properties.Title.title[0].plain_text,
            slug: p.properties.Slug.rich_text[0].plain_text,
            body: p.body,
            updatedAt: p.last_edited_time,
          },
        })
        const digest = generateDigest(data)
        store.set({ id: p.id, data, digest })
      }
      meta.set('last-synced', new Date().toISOString())
    },
  }
}
// src/content.config.ts
import { defineCollection, z } from 'astro:content'
import { notionLoader } from './loaders/notion'

const articles = defineCollection({
  loader: notionLoader({ databaseId: process.env.NOTION_DB_ID! }),
  schema: z.object({
    title: z.string(),
    slug: z.string(),
    body: z.string(),
    updatedAt: z.coerce.date(),
  }),
})

export const collections = { articles }

Pages then consume it the same way as a file-based collection.

---
import { getCollection } from 'astro:content'
const articles = await getCollection('articles')
---
<ul>
  {articles.map(a => <li><a href={`/articles/${a.data.slug}`}>{a.data.title}</a></li>)}
</ul>

Whether the data is Markdown, Notion, Sanity, or your own Postgres — pages don't know. That's the point of the Content Layer.

4.3 Build cache and incremental builds

The Content Layer ships an SQLite-backed cache and only reprocesses changed entries. That's why the second build of a 10k-article site is dramatically faster than the first. A loader can stash its last sync timestamp via meta.get/set, and generateDigest produces a content hash to detect changes.

4.4 The external loader ecosystem

After 5.0 shipped, official and third-party loaders arrived quickly — Storyblok, Hygraph, WordPress, Ghost, Strapi, Sanity, and more. The "Astro = universal frontend for any headless CMS" pitch started actually working in production.

4.5 Live Loaders — runtime content

Coming after GA: Live Content Loaders. These fetch data at request time rather than build time, fitting "static 80% + live 20%" scenarios (price, inventory, FX). The most impressive part is that you keep the same getCollection API — going live is a per-collection choice, not an architectural rewrite.


5. Astro Actions — type-safe forms and mutations

The third big change is Actions. In one phrase: "type-safe server functions with Zod input schemas."

5.1 Definition

// src/actions/index.ts
import { defineAction } from 'astro:actions'
import { z } from 'astro:schema'

export const server = {
  subscribe: defineAction({
    accept: 'form',
    input: z.object({
      email: z.string().email(),
      utm: z.string().optional(),
    }),
    handler: async (input, ctx) => {
      const ip = ctx.request.headers.get('x-forwarded-for')
      await saveSubscriber({ email: input.email, utm: input.utm, ip })
      return { ok: true }
    },
  }),
}

5.2 Invocation — server-rendered form, as is

---
import { actions } from 'astro:actions'
const result = Astro.getActionResult(actions.subscribe)
---
<form method="POST" action={actions.subscribe}>
  <input type="email" name="email" required />
  <input type="hidden" name="utm" value="blog-banner" />
  <button type="submit">Subscribe</button>
  {result?.data?.ok && <p>Thanks!</p>}
  {result?.error && <p class="err">Error — please try again.</p>}
</form>

The form works with JS disabled. With JS on, it becomes progressively enhanced. From client JS you can also call actions.subscribe(input) directly.

5.3 Why it matters

Conceptually it's a sibling of Next.js Server Actions, but Astro fits the "static page with one form on it" scenario more lightly.


6. Vite 6 / Vite 7 integration — the build pipeline

Astro 5 shipped on Vite 6. Subsequent 5.x / 6.x lines pulled in Vite 7. From a developer's seat:

The bigger point — the Astro team works almost in lockstep with Vite core. Vite improvements flow into Astro fast.


7. View Transitions and prefetching — SPA smoothness, MPA weight

One historical weakness of content sites was the white flash on every navigation. The View Transitions API and core prefetching close that gap.

7.1 View Transitions

---
import { ClientRouter } from 'astro:transitions'
---
<head>
  <ClientRouter />
</head>

That one line in your layout makes intra-site navigation MPA-shaped while overlaying browser-native View Transitions on the page change. The header stays put while the body cross-fades; a thumbnail morphs into the next page's hero image.

Name elements to track them across pages.

<img src={post.cover} transition:name={`cover-${post.slug}`} />

7.2 prefetching — pulled into core

Prefetch became part of Astro core in 3.5. In 5, the default-on heuristics are smarter.

// astro.config.mjs
export default defineConfig({
  prefetch: {
    prefetchAll: true,
    defaultStrategy: 'viewport',
  },
})

Strategies are tap, hover, viewport, load. You can override per link with data-astro-prefetch="hover". The result — the next page is already there before the user clicks.

7.3 i18n improvements

i18n stabilized in 4.x and was polished in 5.x.

Almost every content site eventually meets multilingual; having this first-class matters.


8. Astro vs Next.js vs SvelteKit — decision matrix

Now the real point. What to use, where.

8.1 One-line philosophies

8.2 Item-by-item

ItemAstro 5Next.js (RSC)SvelteKit
Default JS payloadSmallest (zero by default)Medium-highSmall
Full-stack depthShallow-mediumDeepMedium
Content modelContent Layer (first-class)App Router + MDX (add-on)Roll your own
Forms / mutationsActionsServer Actionsform actions
Dynamic personalizationServer IslandsRSC + Suspensestreaming + load
i18nCore i18nLibraryLibrary
HostingStatic + any adapterVercel-optimizedVarious adapters
Learning curveLowMedium-highLow-medium
Component compatibilityReact/Svelte/Vue/Solid/etc.React onlySvelte only

8.3 Scenarios

One line — content first, pick Astro; application first, pick Next.js or SvelteKit.


9. A real-world shape — a content-heavy site on Astro 5

A hypothetical media company: 100M PV/month news site. Here's what the architecture looks like on Astro 5.

9.1 Decisions per page

CDN hit rate clears 95%. A 99/1 page gets a 99/1 cost structure.

9.2 Data flow

9.3 Build time

This is likely faster than a Next.js static build of the same size. That said, Next.js's ISR + on-demand revalidation model has its own appeal — this isn't an absolute win.


10. Migrating Astro 4 to 5 — the honest version

The most practical question — how much does going from 4 to 5 hurt?

10.1 Good news

10.2 Where you'll actually spend time

  1. Content Collections to Content Layer

    • Add loader: glob({...}) to src/content/config.ts.
    • Some getEntry / getCollection signatures shifted slightly.
    • The build introduces a new cache; mount the cache directory in CI.
  2. Astro.glob removed

    • Subsumed by the Content Layer. Code that hand-rolled Markdown discovery moves to getCollection.
  3. Image policy changes

    • Some astro:assets options were tidied. External image domain allowlists are stricter.
  4. Adapter upgrades

    • Vercel, Netlify, Cloudflare, and Node adapters all need 5.x versions.
  5. Vite 6 plugin compatibility

    • If you've got custom Vite plugins, verify 5/6 compatibility.

10.3 Migration order

  1. Bring Astro 4 to the latest patch and clear deprecation warnings.
  2. Run pnpm dlx @astrojs/upgrade for the major jump.
  3. Make astro check pass.
  4. Try Server Islands on one page.
  5. Migrate one collection to the Content Layer.
  6. Expand incrementally.

Even a large site usually wraps up in days. Far lighter than the Pages-to-App-Router migration in Next.js.


11. The honest limits — where Astro still wins, and where it loses

The non-marketing part.

11.1 Where Astro is still strong

11.2 Where it's weaker

11.3 One line

"If your site is a book, pick Astro. If your site is a tool, pick Next.js. If it's in between, evaluate SvelteKit too."


Epilogue — checklist and what's next

12.1 Adoption checklist

12.2 Anti-patterns

12.3 What's next

The series promise in one line — the standard for content sites has shifted again, and its name is Astro 5.


References

Comments

No comments yet.

Sign in to leave a comment