Astro Server Islands vs Next.js Partial Prerendering (PPR): Internal Mechanics and Caching Strategy Comparison
Recommended for: Frontend developers who have used Next.js App Router or Astro in production. Familiarity with
React SuspenseandServer Componentsconcepts will make this a much smoother read.
When I first put these two technologies side by side, I honestly thought, "Aren't these basically the same thing?" They both share the idea of "send fast static HTML first, fill in slow dynamic data later." But when you dig into how dynamic content is actually delivered, the design philosophies are completely different.
Astro Server Islands splits the static shell and dynamic islands into entirely separate requests, allowing each to be cached independently. Next.js PPR takes the opposite approach, streaming static and dynamic parts sequentially within a single HTTP stream. This difference determines everything — caching strategy, infrastructure requirements, and the tradeoffs you feel in practice.
In this article, we'll dissect the internal mechanics of both technologies down to props serialization, island endpoints, and postponedState management, and establish concrete criteria for judging which one fits your service. This article is written based on Next.js 16 / Astro 5.x.
Core Concepts
Astro Server Islands — Dynamic Components Isolated Like Islands
Astro was originally known for its Islands Architecture, which places interactive UI components "like islands" on a sea of static HTML. The core idea is "only ship JavaScript where you need it" — and here, interaction is client-side. Server Islands extends this concept into the server-rendering domain: instead of the browser, it isolates components that render dynamically on the server as islands.
When you attach server:defer to a component, that spot is left as a slot (placeholder) at build time, and after the browser loads the page, a separate GET request is sent to the /_server_island/ internal route to fetch and fill in the dynamic component.
---
// ProductPage.astro
import UserAvatar from '../components/UserAvatar.astro';
import ProductContent from '../components/ProductContent.astro';
---
<html>
<body>
<!-- Rendered at build time → cached on CDN -->
<ProductContent />
<!-- Separate GET request to /_server_island/ route on each request -->
<UserAvatar server:defer>
<div slot="fallback">Loading...</div>
</UserAvatar>
</body>
</html>There's an interesting implementation detail here. Props passed to an island are sent as an AES-encrypted string in the URL query parameters. If the props are too large (exceeding URL length limits), the request automatically switches to POST — and since POST requests are not cached by the browser, the CDN caching benefit disappears. This is exactly why you need to minimize props.
AES Encryption in Props Serialization — Astro uses the Web Crypto API to encrypt props so they aren't exposed directly in the URL. You need to know what URL the
/_server_island/endpoint sends requests to in order to configure CDN cache rules and debug properly. In rolling deployments or multi-region environments, encryption key mismatch issues can arise, so it's recommended to establish a key management strategy in advance.
Next.js PPR (Partial Prerendering) — Static and Dynamic Together in One Stream
Next.js PPR takes a fundamentally different approach. It uses <Suspense> boundaries to separate static and dynamic regions, then streams the static shell and dynamic content sequentially within a single HTTP stream.
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import ProductInfo from './ProductInfo';
import DynamicPricing from './DynamicPricing';
export default function ProductPage() {
return (
<main>
{/* Static shell rendered at build time */}
<ProductInfo />
{/* Delivered via streaming on request */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPricing />
</Suspense>
</main>
);
}Internally, React's prerender() + postpone internal API are the key. At build time, when Next.js encounters a <Suspense> boundary, it "postpones" rendering and generates a serialized blob called postponedState.
postponedState — A chunk of metadata about where rendering was paused. The CDN holds the static shell HTML, and
postponedStateis abstracted away in Next.js's internal cache layer. On Vercel, this is handled automatically, but in self-hosted environments, separate storage (KV, Blob, etc.) may be required depending on the adapter. When a request comes in, the CDN immediately returns the static shell, and the origin server continues streaming the dynamic parts based onpostponedState.
Changes in Next.js 16 — 'use cache' and Fully Opt-in Caching
Version Note: Up to Next.js 15, PPR was activated with
experimental.ppr: true. In Next.js 16, as Cache Components entered the stabilization phase, the activation method changed toexperimental.cacheComponents: true. The code examples in this article are based on Next.js 16.
The implicit automatic fetch caching that used to work silently in the previous App Router has been completely removed, replaced by a fully opt-in model where caching is explicitly declared with the 'use cache' directive.
// next.config.js
module.exports = {
experimental: {
cacheComponents: true,
},
};// Declaring caching at the component level
'use cache';
export async function ProductReviews({ id }: { id: string }) {
const reviews = await fetchReviews(id);
return <ReviewList reviews={reviews} />;
}At first you might think "why remove automatic caching?" — but if you've ever spent time debugging "why is this data stale?" and traced it back to implicit caching, this decision will feel like a welcome change.
So how do the two technologies actually differ in real projects?
Practical Application
Example 1: E-commerce Product Detail Page (Astro Server Islands)
The pattern here is to cache product names, images, and descriptions on a CDN with long TTLs of weeks to months, while isolating per-user avatars and real-time stock/pricing information as islands. The biggest advantage is that a slow island doesn't block a fast island.
---
// app/pages/product/[id].astro
export const prerender = true; // SSG mode: generates static HTML at build time
// must be declared to use server:defer islands
import ProductHero from '../components/ProductHero.astro';
import UserAvatar from '../components/UserAvatar.astro';
import StockBadge from '../components/StockBadge.astro';
import PriceTag from '../components/PriceTag.astro';
const { id } = Astro.params;
const product = await getProduct(id); // fetched at build time, included in static HTML
---
<html>
<body>
<!-- Static: CDN cached, rendered at build time -->
<ProductHero product={product} />
<!-- Dynamic islands: each loads independently via /_server_island/ route -->
<UserAvatar server:defer>
<div slot="fallback" class="avatar-skeleton" />
</UserAvatar>
<!-- Only passing an ID string, so no URL size issues -->
<StockBadge productId={id} server:defer>
<div slot="fallback" class="badge-skeleton" />
</StockBadge>
<PriceTag productId={id} server:defer>
<span slot="fallback">--</span>
</PriceTag>
</body>
</html>| Component | Render Timing | Caching Strategy | Notes |
|---|---|---|---|
ProductHero |
Build time | CDN, long TTL | Can be updated via on-demand invalidation or redeployment |
UserAvatar |
On request (GET) | Per-user no-cache | Personalized data |
StockBadge |
On request (GET) | Short TTL (30s) | Stock doesn't change that frequently |
PriceTag |
On request (GET) | no-cache | Real-time pricing |
Example 2: E-commerce Product Detail Page (Next.js PPR)
Implementing the same scenario with PPR handles everything in a single stream without additional HTTP requests. The key point is that TTFB is fixed at the CDN edge level, independent of API latency.
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import ProductHero from './components/ProductHero';
import UserAvatar from './components/UserAvatar';
import StockBadge from './components/StockBadge';
import PriceTag from './components/PriceTag';
// generateStaticParams operates independently of PPR.
// It's used to pre-generate routes as SSG at build time;
// PPR itself works without this function.
export async function generateStaticParams() {
const products = await getTopProducts();
return products.map(p => ({ id: p.id }));
}
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<main>
{/* Static shell: rendered at build time */}
<ProductHero id={params.id} />
{/* Dynamic regions: delivered in a single stream */}
<Suspense fallback={<AvatarSkeleton />}>
<UserAvatar />
</Suspense>
<div className="product-meta">
<Suspense fallback={<StockSkeleton />}>
<StockBadge productId={params.id} />
</Suspense>
<Suspense fallback={<PriceSkeleton />}>
<PriceTag productId={params.id} />
</Suspense>
</div>
</main>
);
}// app/product/[id]/components/PriceTag.tsx
'use cache';
import { cacheTag, cacheLife } from 'next/cache';
export async function PriceTag({ productId }: { productId: string }) {
cacheTag(`price-${productId}`);
cacheLife('seconds'); // short TTL — cached in seconds, not no-cache
const price = await fetchCurrentPrice(productId);
return <span className="price">{price.formatted}</span>;
}| Characteristic | Astro Server Islands | Next.js PPR |
|---|---|---|
| HTTP request count | 1 (shell) + N (islands) | 1 (streaming) |
| TTFB | Instant at CDN edge | Instant at CDN edge |
| Island independence | Fully independent | Per Suspense boundary |
| Cache declaration | Cache-Control headers |
'use cache' directive |
| Infrastructure dependency | Low | Vercel or dedicated adapter |
Pros and Cons Analysis
Advantages
| Item | Astro Server Islands | Next.js PPR |
|---|---|---|
| CDN cacheability | Static shell 100% cacheable | Limited due to streaming response |
| Additional RTT | Independent request per island, but processed in parallel | None, single stream |
| Infrastructure portability | Works anywhere: Node.js, Docker, Cloudflare Workers, etc. | Requires Vercel or PPR adapter |
| Ecosystem integration | Framework-agnostic | Tightly integrated with React Suspense and Server Components |
| Fault isolation | One island error doesn't affect other islands | Depends on Suspense boundary design |
Streaming vs Separate Requests — PPR's single stream makes it difficult for CDNs to cache streaming chunks at the byte level. Astro's separate requests allow each island response to carry its own independent
Cache-Controlheader, giving much more flexible per-island cache control.
RTT (Round Trip Time) — The time it takes for one round trip of a request-response between client and server. Unlike Astro, where each island incurs an additional RTT, PPR carries everything in the first response stream, adding no extra RTT.
Disadvantages and Caveats
Landmines filtered from experience.
| Item | Details | Mitigation |
|---|---|---|
| [Islands] Waterfall delay | Island requests start after JS loads → possible additional delay | Use <link rel="preload"> hints for critical islands |
| [Islands] Props size limit | Exceeding URL length switches to POST → CDN caching not possible | Pass only IDs as props, fetch data inside the island |
| [Islands] Encryption key mismatch | Key version mismatch can occur in rolling deployments and multi-region environments | Establish a clear key rotation strategy during deployments |
| [PPR] postponedState management | Self-hosted environments may need separate storage depending on the adapter | Abstract away using Vercel or official adapters |
| [PPR] Suspense boundary design | Incorrect placement can cause the entire page to fall back to SSR | Minimize boundaries based on whether cookies(), headers(), or searchParams are used |
| [PPR] Platform dependency | Optimal behavior only on Vercel or dedicated adapters | Confirm in advance whether a PPR adapter implementation is needed for self-hosting |
The Most Common Mistakes in Practice
-
Putting too much data in props in Astro — If props exceed the URL length limit, GET switches to POST and CDN cache benefits disappear. I initially passed whole objects and only discovered later that nothing was being cached. The safe pattern is to pass only the minimum required ID to the island and fetch data inside the island server component.
-
Setting
<Suspense>boundaries too wide in PPR — If dynamic components aren't properly wrapped, the entire boundary is treated as dynamic. You can maximize the static shell by setting boundaries to the smallest possible unit based on the question "does this component usecookies(),headers(), orsearchParams?" -
Applying these technologies to fully dynamic pages like social feeds — Both official documentations state that both technologies "require cacheable main content to be effective." For cases like user timelines where even the shell is personalized, neither technology provides meaningful benefits.
Closing Thoughts
"If independent per-island cache control and infrastructure portability come first, choose Astro Server Islands; if single-request performance and React ecosystem integration come first, choose Next.js PPR" — this one sentence can be your starting point for making a decision.
Having used both, the trickier-than-expected part was the same for both: "when and where to draw the boundary." In Astro, the key was the props size limit; in PPR, it was Suspense boundary placement — and this intuition doesn't develop well just from reading documentation. It required actually digging into the code and examining build output.
Three steps you can start right now:
-
Analyze the static/dynamic ratio of your existing SSR pages — Look at page requests in the Chrome DevTools Network tab and try to separate out "what data is the same for all users?" The higher this ratio, the more benefit you can expect from both technologies.
-
If you'd like to try Astro — Create a project with
npx create astro@latest --template minimal, declareexport const prerender = trueat the top of the page, and attachserver:deferto one component that calls a slow API. You can also check out server-islands.com first to see how island requests actually travel back and forth. -
If you'd like to try Next.js PPR — With Next.js 16, add
experimental: { cacheComponents: true }tonext.config.jsand wrap a component with a slowfetchin<Suspense>. Check the build output to see if the route displays the◐(PPR) symbol — that gives you instant confirmation.
References
- Server Islands | Astro Docs
- The Future of Astro: Server Islands | Astro Blog
- Astro 4.12 Release Notes — Server Islands Introduction | Astro Blog
- How Astro's server islands deliver progressive rendering | Netlify Developers
- Partial Prerendering (Next.js 15) | Next.js Docs
- Cache Components (Next.js 16) | Next.js Docs
- How Partial Prerendering Works Under the Hood | Wyatt Johnson
- Partial prerendering: Building towards a new default rendering model | Vercel Blog
- Next.js 15 Partial Prerendering: Real-World Patterns and Tradeoffs | wolf-tech.io
- Next.js PPR と比較して理解する Astro Server Islands | Zenn
- Next.js PPR Deep Dive | pockit.tools