Skip to content

Server Components in practice: 11 client islands, and why each exists

Jaskaran Singh4 min read

The default in the App Router is a server component, and the default is where most of the value is. This entire site — fourteen home sections, seven project pages, a blog — is rendered on the server. The browser downloads JavaScript for eleven small islands, and each one has a specific job.

This is the inventory, and the reasoning behind each entry.

The default nobody notices

Server components cannot hold state, cannot use effects, and cannot attach event handlers. What they can do is fetch data, read files, and render markup — which is most of what a content site does. The payoff is not just bundle size. It is that content correctness becomes independent of JavaScript: if the article you are reading fails to hydrate, nothing about it changes.

The discipline that keeps this clean is simple: a component earns a "use client" directive by needing one of three things — state, an effect, or an event handler.

The eleven islands

IslandWhy it is a client component
Announcement bannerReads sessionStorage to remember dismissal
Desktop navigationDropdown open state, scroll-spy via IntersectionObserver
Mobile navigationMenu open state, Escape handling
Theme toggleCalls setTheme and reads the resolved theme
Hero terminalWEB/MOBILE tab state, clipboard access
Marquee pauseToggles animation-play-state
Stat countersAnimates numbers when they scroll into view
TestimonialsEmbla carousel with autoplay and drag
FAQ accordionSingle-open state, aria-expanded
Contact formuseActionState, pending state, focus management
Back to topScroll listener

Eleven components, each justified by a browser API or a piece of interactive state. Everything else — project cards, service blocks, the footer, the skills grid, the article you are reading — is server-rendered and ships no JavaScript at all.

Passing data across the boundary

The rule for props crossing from server to client is that they must be serializable — no functions, no class instances. In practice this shapes component APIs in a good way. The blog index wants client-side search and category filtering, but the post data comes from MDX files on disk. The server page does the file reading and hands the client a plain array:

tsx
// app/blog/page.tsx — server
const posts = getGridPosts().map((post) => ({
  slug: post.slug,
  title: post.title,
  category: post.category,
  cover: post.cover,
  author: site.name,
}));

return <BlogList posts={posts} categories={getCategories()} />;

The client component receives exactly what it needs to render and filter cards. The MDX compiler, the file system, and the frontmatter parser never enter the client bundle.

Children cross the boundary fine

A less obvious pattern: client components can wrap server-rendered content, because children are rendered on the server and passed as an already-created element tree. That is how scroll reveals work here without turning content into client components:

tsx
// server page
<Reveal delay={0.1}>
  <ProjectCard project={project} />  {/* renders on the server */}
</Reveal>

Reveal is a client component using Motion's whileInView. ProjectCard stays a server component; only the transform and opacity are animated from the client. The alternative — marking the card itself client — would ship its markup and props to the browser for no benefit.

What stays on the server

Three things deliberately never cross the boundary:

Content. Projects, services, testimonials, FAQs and now blog posts are typed data files read at build time. Structured data (JSON-LD) is generated from the same objects and rendered into the page head — crawlers see it without executing anything.

MDX compilation. Articles are compiled during the build and served as static HTML. There is no client-side markdown parser and no syntax highlighter running in the browser; Shiki colours every code block at build time.

Layout and prose. The reading experience has no client JavaScript except the two small controls in the article rails — a table of contents that tracks scroll position, and copy buttons on code blocks.

The cost of getting it wrong

The failure mode is not an error message; it is a slow silent regression. One "use client" at the top of a section file pulls everything below it into the browser bundle. During this build, a single misplaced directive duplicated every testimonial card's markup into the client payload — invisible in development, obvious in the build output.

The guardrail is to treat each directive as a design decision: name the state or browser API the component needs, and if you cannot name one, move the directive down to the leaf that actually has it. Eleven islands for a whole site is not a constraint — it is what the default gets you when you stop fighting it.

More posts.