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
| Island | Why it is a client component |
|---|---|
| Announcement banner | Reads sessionStorage to remember dismissal |
| Desktop navigation | Dropdown open state, scroll-spy via IntersectionObserver |
| Mobile navigation | Menu open state, Escape handling |
| Theme toggle | Calls setTheme and reads the resolved theme |
| Hero terminal | WEB/MOBILE tab state, clipboard access |
| Marquee pause | Toggles animation-play-state |
| Stat counters | Animates numbers when they scroll into view |
| Testimonials | Embla carousel with autoplay and drag |
| FAQ accordion | Single-open state, aria-expanded |
| Contact form | useActionState, pending state, focus management |
| Back to top | Scroll 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:
// 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:
// 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.