Why Waku Defaults to Server Components – A Practical Guide
Waku defaults to React Server Components for every route, turning only the smallest interactive widgets into client code. This post explains why, how file‑based routing shapes that boundary, walks through a filterable list example, and discusses trade‑offs you’ll need to manage.
05 Feb 2026, 21:14 UTC

Problem: Where to Put Interactivity in a Waku App?
When you start a new Waku project, you’re immediately faced with the question: Should I render this page on the server or make the whole UI a client‑side SPA? The answer isn’t obvious if you’re used to Next.js or Remix, where the default is a hybrid of server‑rendered pages and client‑side navigation. Waku flips that model: React Server Components (RSC) are the default rendering unit. Understanding why and how this choice shapes your codebase is key to building maintainable, fast pages.
Thesis: Server Components as the Default Rendering Unit
Waku’s core promise is “the minimal React framework.” To keep the surface area small, the framework assumes every route module is a server component unless you explicitly opt out. The use client directive, placed at the top of a file, turns that module (and everything it imports) into a client component. This mirrors the mental model of RSC: data fetching and layout composition happen on the server, while only the interactive bits live in the browser.
Why is this useful?
- Server rendering eliminates the need for a JavaScript bundle for the majority of the page, reducing first‑paint time.
- Data fetching stays on the server, so you can use any database driver or file system API without bundling it for the client.
- Only the smallest, most interactive widgets are shipped to the browser, keeping bundle size minimal.
File‑Based Routing Shapes the Boundary
Waku’s routing is file‑system driven, similar to Next.js. Each file under src/pages becomes a route, and src/layouts files compose around those routes. Because the boundary is established at the file level, you can audit the client/server split by looking at a single file:
src/pages/
├─ index.tsx // server component – default
├─ dashboard.tsx // server component – default
└─ settings.tsx // client component – use client at top
Placing the directive as low as possible is important. If you put use client in src/pages/dashboard.tsx, the entire dashboard tree becomes client‑side, even if only a tiny widget needs interactivity. Instead, extract that widget into its own file and mark it client‑side.
Worked Example: A Filterable List
Let’s walk through a concrete scenario: a page that displays a list of products fetched from a database, with a client‑side filter input that updates the list without a full page reload.
1. Create the Route (Server Component)
// src/pages/products.tsx
import { getProducts } from '../lib/db';
import ProductList from '../components/ProductList';
export default async function ProductsPage() {
const products = await getProducts();
return (
<section>
<h1>Products</h1>
<ProductList products={products} />
</section>
);
}
Key points:
- The file is a server component by default (no
use client). It can importgetProductswhich uses Node APIs. - It passes a plain array of product objects to
ProductList.
2. Create the List (Server Component)
// src/components/ProductList.tsx
import type { Product } from '../lib/types';
import FilterableRow from './FilterableRow';
export default function ProductList({ products }: { products: Product[] }) {
return (
<ul>
{products.map(p =>
<FilterableRow key={p.id} product={p} />
)}
</ul>
);
}
Again, a server component. It renders a list of FilterableRow components.
3. Create the Row (Client Component)
// src/components/FilterableRow.tsx
'use client';
import { useState } from 'react';
import type { Product } from '../lib/types';
export default function FilterableRow({ product }: { product: Product }) {
const [filter, setFilter] = useState('');
const matches = product.name.toLowerCase().includes(filter.toLowerCase());
return (
<li>
<input
type="text"
placeholder="Filter by name"
value={filter}
onChange={e => setFilter(e.target.value)}
/>
<div>
{matches ? product.name : '—'}
</div>
</li>
);
}
Notice the use client directive. Only this component (and anything it imports) is sent to the browser. The product prop is a plain object, so it serializes cleanly across the boundary.
4. Verify the Boundary
Run a dev build and inspect the client bundle:
npm run dev
# In the browser console, run
console.log(window.__waku__); // Waku injects a global that lists client modules
Alternatively, after a production build, look in public/_client (the default output folder) for FilterableRow.js and confirm that ProductList.js is absent. If you accidentally import a server‑only module inside FilterableRow.tsx, the build will fail with an error about “server‑only imports in client code.”
Trade‑offs and Limitations
- Prop Serialization: Only serializable values (strings, numbers, arrays, plain objects) can cross from server to client. Passing functions, class instances, or Dates will break rendering or bloat the payload.
- Data Freshness: The client component receives the data snapshot at render time. If the user interacts and the data changes on the server, you’ll need to refetch or use a client‑side data layer (e.g., SWR) to keep the UI in sync.
- Version Sensitivity: Waku is pre‑1.0; routing conventions, config keys, and RSC semantics can change between releases. Pin a specific version in
package.jsonand check the changelog before making architectural decisions. - Bundle Size Leakage: If you place
use clienttoo high, you’ll ship unnecessary server‑only logic to the browser. Auditing thepublic/_clientfolder after a build is a quick sanity check.
Actionable Takeaway
When you scaffold a new Waku app, follow these steps to keep the RSC model clean:
- Pin the framework version. Add
"waku": "^0.4.0"topackage.jsonand runnpm install. - Assume server components by default. Only add
use clientat the lowest possible file that needs interactivity. - Audit the client bundle. After
npm run build, inspectpublic/_clientor use thewindow.__waku__global to confirm the boundary. - Keep props serializable. If you need to pass functions, lift the logic into a client component that receives the data and handles the interaction.
- Document the boundary. In a shared design doc, note which routes are server‑only and which interactive widgets live in the client bundle. This makes onboarding new developers faster.
By treating every route as a server component and only sprinkling client components where user interaction is required, you’ll enjoy the performance benefits of server‑side rendering while still delivering a responsive UI. The file‑based routing system makes the boundary explicit and auditable, giving you confidence that your bundle stays lean and your code stays maintainable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.