← Back to Blog
News

Progressive Disclosure of Interactivity: Islands in the Next.js App Router

By Matthew MotorsJanuary 12, 2026

Progressive Disclosure of Interactivity: Islands in the Next.js App Router

Modern web applications increasingly balance rich interactivity with strict performance budgets. A proven way to do this is to progressively disclose interactivity: render most of the page as fast, static or server-rendered HTML and hydrate only the parts that truly need client-side behavior. In the Next.js App Router, that pattern maps naturally to an islands architecture powered by React Server Components (RSC) and selective, lazy-hydrated client components.

What is islands architecture?

Islands architecture is a design pattern where the page is primarily server-rendered HTML with small, isolated “islands” of interactivity that hydrate on the client only when needed. This approach reduces JavaScript shipped to the browser, lowers main-thread contention, and improves user-perceived performance. For a concise overview, see the Astro documentation on islands, which popularized the term in modern tooling ecosystems (Astro: Islands Architecture).

Islands in the Next.js App Router

The Next.js App Router defaults to React Server Components, meaning components are server-rendered unless explicitly marked as client. This makes it straightforward to build pages that are mostly server components while promoting client components only for interactivity. See the Next.js docs on server components and rendering strategies (Next.js: Server Components).

Identifying routes and components for progressive interactivity

  • Keep content, navigation, footers, and product descriptions as server components by default.
  • Promote only stateful or event-heavy UI to client components: cart widgets, search autocomplete, filters, live chat, maps, and charts.
  • Defer non-critical islands until interaction or viewport visibility (lazy load).
  • Stream server components and show loading states with React Suspense to improve perceived speed.

Implementation: splitting server and client, with lazy-loaded islands

The App Router makes server-by-default the norm. Add "use client" only where interactivity is required, and gate heavy client code behind dynamic() with Suspense.

Server-first route with client islands

/* app/product/[slug]/page.tsx (Server Component by default) */
import { Suspense } from 'react';
import Image from 'next/image';
import dynamic from 'next/dynamic';
import { getProduct } from '@/lib/data';

// Defer non-critical interactivity
const AddToCartIsland = dynamic(() => import('@/components/AddToCart'), {
  ssr: false, // do not block HTML; hydrate only on the client
  loading: () => <button disabled>Loading…</button>,
});

// Lazy load a heavier visualization only when it scrolls into view
const ReviewsChartIsland = dynamic(
  () => import('@/components/ReviewsChart'),
  { ssr: false }
);

export default async function ProductPage({ params }: { params: { slug: string } }) {
  const product = await getProduct(params.slug); // runs on the server

  return (
    <article>
      <h1>{product.title}</h1>
      <Image src={product.image} alt={product.title} width={1200} height={800} priority />
      <p>{product.description}</p>

      {/* Interactive island */}
      <Suspense fallback={<button disabled>…</button>} >
        <AddToCartIsland productId={product.id} />
      </Suspense>

      {/* Deferred, non-critical island */}
      <section>
        <h2>Customer reviews</h2>
        <Suspense fallback={<div>Loading chart…</div>}>
          <ReviewsChartIsland productId={product.id} />
        </Suspense>
      </section>
    </article>
  );
}

Client island example

/* components/AddToCart.tsx (Client Component) */
"use client";
import { useTransition } from 'react';

export default function AddToCart({ productId }: { productId: string }) {
  const [pending, startTransition] = useTransition();

  const add = async () => {
    startTransition(async () => {
      await fetch('/api/cart', { method: 'POST', body: JSON.stringify({ productId }) });
    });
  };

  return (
    <button onClick={add} disabled={pending}>
      {pending ? 'Adding…' : 'Add to cart'}
    </button>
  );
}

For more patterns around App Router, RSC, streaming, and lazy loading, see the official Next.js guides on loading UI and streaming (Next.js: Loading UI and Streaming) and lazy loading (Next.js: Lazy Loading).

Quantifying impact on INP and LCP

Core Web Vitals offer clear targets: good LCP under 2.5s and good INP under 200ms. INP replaced FID as a Core Web Vital in March 2024 (web.dev: Interaction to Next Paint). LCP guidance is here (web.dev: Largest Contentful Paint).

  • Reducing client JavaScript typically shortens main-thread work and long tasks, the primary cause of poor INP. The V8 team has shown how parse/compile/execute costs scale with JS size; on mobile hardware, hundreds of milliseconds can be spent before the page becomes responsive (V8: The Cost of JavaScript, 2019).
  • Server-rendered HTML and streaming improve LCP by delivering the LCP element (often hero image or headline) early. Preloading the LCP resource and minimizing render-blocking work are key recommendations (web.dev: Improve LCP).

What does that mean in practice?

  • If migrating a product page to RSC + islands removes 150–300 KB of client-side JS from the initial route, mobile devices can avoid roughly 150–400 ms of parse/compile/execute work, depending on CPU and code characteristics. That reduction often moves INP into the “good” range by curbing long tasks (see the V8 analysis above).
  • Ensuring the hero image is server-rendered and preloaded, while deferring non-critical islands, commonly trims LCP by 200–500 ms on real mobile networks, especially when combined with image optimization and HTTP/2 prioritization. Your exact gains depend on network, device, and content mix.

Measure with field data first (CrUX, RUM) and validate implementation in lab tools (Lighthouse, WebPageTest). Track improvements in long tasks, main-thread time, and total JS executed alongside INP/LCP.

Real-world patterns you can emulate

  • Commercial product pages: Keep gallery, description, and specs as server components; defer reviews charts and recommendation carousels as lazy islands; hydrate cart and inventory widgets on interaction or viewport.
  • Content sites: Render articles and TOC on the server; hydrate code playgrounds, comments, and search on demand.
  • Maps and analytics: Load maps and heavy visualizations when a user expands a panel or scrolls to that section instead of at page start.

Focused guidance for each key concept

islands architecture

Think of your page as a static landmass with small interactive islands. Most HTML arrives fully rendered from the server; only targeted components hydrate. This reduces bundle size, limits hydration work, and improves resilience if JavaScript fails. See the conceptual overview at Astro.

Next.js

The App Router embraces server-by-default rendering and co-locates routing, data fetching, and streaming. This default encourages you to start with fast, server-rendered pages and add interactivity surgically. Refer to Next.js App Router docs.

partial hydration

Partial hydration hydrates only selected components rather than the entire page. In Next.js, you achieve this by keeping most components as server components and declaring islands as client components. Lazy loading pushes hydration even further to when the island is needed.

performance

Reducing JavaScript shipped and deferring non-critical work lowers main-thread pressure, which directly benefits INP and, indirectly, LCP. Streamed HTML and prioritized media help your LCP render earlier. Always verify with field data and budget for regressions with automated checks.

React Server Components

RSC let you render UI on the server without sending its component code to the client, cutting bundle size and eliminating duplicated data fetching on the client. Learn how RSC work in Next.js here: Next.js: Server Components.

lazy loading

Use next/dynamic to defer hydration and code download for non-critical islands. Combine with Suspense boundaries and viewport-based loading to avoid blocking the critical path. Reference: Next.js: Lazy Loading.

DX

Developer experience improves because data fetching and rendering default to the server, reducing client-state complexity. You add "use client" only where necessary, keeping islands small and focused. The App Router’s conventions simplify routing, loading states, and error boundaries while scaling to complex apps.

Next steps

If you are planning or auditing a Next.js build, a good first step is inventorying interactivity: list all components on a representative route, flag which truly require client-side state, and defer everything else. For guidance on production-grade Next.js patterns and performance-first builds, explore our pages on Next.js services and frontend development services.