Fix: Waku Not Working — Server Components Not Rendering, Client Components Not Interactive, or Build Errors
Part of: React & Frontend Errors
Quick Answer
How to fix Waku React framework issues — RSC setup, server and client component patterns, data fetching, routing, layout system, and deployment configuration.
The Problem
Server Components render on the server but client components aren’t interactive:
// src/pages/index.tsx
'use client';
export default function HomePage() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
// Button renders but clicking does nothingOr the dev server shows a blank page:
npx waku dev
# Server starts but browser shows white screenOr imports between server and client components fail:
Error: Cannot import server-only module from a client componentWhy This Happens
Waku is a minimal React framework focused on React Server Components (RSC). It uses a different mental model than Next.js, and most “it doesn’t work” reports come from applying Next.js intuitions to a framework that does not share them.
Waku is RSC-first. Every component is a Server Component until you explicitly opt out with a 'use client' directive at the top of a file. That single rule explains the majority of broken pages. If you import useState, useEffect, or any browser-only API in a file that does not start with 'use client', the build either fails with “Cannot use hooks in a Server Component” or the component silently renders as static HTML with no interactivity. Next.js shipped the same rule, but Next.js also auto-injects a client boundary around the App Router pages directory in some configurations — Waku does not. The directive is the only boundary.
The entry point and routing follow conventions you have to learn. Waku uses src/entries.tsx as the application entry. That file exports a createPages factory which registers your routes, layouts, and rendering modes (static vs dynamic). If the file does not exist, or it exists but throws during module load, the dev server shows a blank page with no useful error in the browser — the failure shows up only in the terminal where you started waku dev. Routing is file-based under src/pages/ and pages are Server Components by default. They can render Client Components as children, but they cannot themselves call useState. Data fetching is done with async Server Components that await directly in render — there is no getServerSideProps, no loader, no useQuery requirement.
Server-only modules and client-only modules live in separate bundles. Importing a module that uses fs, crypto, or a database driver from inside a Client Component throws “Cannot import server-only module from a client component” at build time. The fix is to keep server logic in Server Components or in 'use server' action files and pass the resulting data down as props.
Version History: Waku and the RSC-First Bet
Waku is the work of Daishi Kato, the author of Jotai, Zustand, Valtio, and React Tracked. The project started in 2023 as a minimal demonstration of what a React framework looks like if you build it around React Server Components from day one instead of bolting them onto an existing SSR framework. That heritage matters: Waku is intentionally small, the conventions are sparse, and the maintainer optimizes for “React’s official RSC story, with no extra layers” rather than for feature parity with Next.js.
Waku is still on the 0.x line as of the v4 React cycle. Each minor (0.18, 0.19, 0.20, 0.21) has introduced breaking changes to the createPages API, the static-vs-dynamic render flags, and how layouts compose. Pin the version in your package.json exactly — a ^0.20.0 will happily resolve to 0.21 and break your build with messages that do not obviously map to the upgrade. The README and the release notes on GitHub are the canonical reference; the website docs lag behind the package by one or two minors.
Compared to Next.js Server Components, Waku is a different shape of the same idea. Next.js wraps RSC in a metadata API, an App Router with parallel routes and intercepting routes, route handlers, and a fully managed Vercel deployment path. Waku ships the RSC runtime and almost nothing else: no built-in API routes (you call 'use server' actions or run a separate API server), no metadata API (you set <title> and <meta> directly in your root layout), no middleware pipeline. Compared to TanStack Start, which is a Vinxi-based full-stack framework that also supports server functions, Waku is the lighter-weight, RSC-purer option. Choose Waku when you want to learn the RSC mental model without the Next.js wrapper; choose Next.js or TanStack Start when you need the surrounding ecosystem.
The static-vs-dynamic distinction in Waku is more explicit than in Next.js. Every page registered with createPage declares a render mode: 'static' means the page is pre-rendered at build time and the resulting HTML is served as a static file; 'dynamic' means the page is rendered on each request. Next.js infers this from whether your page uses dynamic functions (cookies(), headers(), noStore()); Waku makes you choose. The trade-off is verbosity in exchange for clarity. If your build produces an empty /dist/public/ directory, the most likely cause is that all your pages are registered as 'dynamic' and the build has nothing to pre-render. Conversely, if a page that should be live-rendered is served as stale HTML, you registered it as 'static' and the build snapshot is what your visitors see until the next deploy.
Waku’s bundle output is also smaller than equivalent Next.js apps because there is no built-in metadata API, no image optimization runtime, and no analytics endpoint shipped with the framework. That is a feature if you want a minimal RSC server; it is a missing piece if you expected those features to be present. Set head tags manually in the root layout, run image optimization at build time with a separate tool (or use a CDN that does it on the fly), and add analytics as a Client Component near the root.
Fix 1: Project Setup
npm create waku@latest my-app
cd my-app
npm install
npm run dev// src/entries.tsx — application entry
import { createPages } from 'waku';
export default createPages(async ({ createPage, createLayout }) => {
// Root layout
createLayout({
render: 'static',
path: '/',
component: RootLayout,
});
// Pages
createPage({
render: 'static',
path: '/',
component: HomePage,
});
createPage({
render: 'dynamic',
path: '/posts',
component: PostsPage,
});
createPage({
render: 'dynamic',
path: '/posts/[slug]',
component: PostPage,
});
});// src/components/RootLayout.tsx — Server Component (default)
import type { ReactNode } from 'react';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Waku App</title>
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/posts">Posts</a>
</nav>
</header>
<main>{children}</main>
</body>
</html>
);
}Fix 2: Server Components (Data Fetching)
// src/pages/posts.tsx — Server Component (async)
// No 'use client' → runs on server → can fetch data directly
export default async function PostsPage() {
// Fetch data directly in the component — no useEffect needed
const posts = await db.query.posts.findMany({
where: eq(posts.published, true),
orderBy: desc(posts.createdAt),
});
return (
<div>
<h1>Blog Posts</h1>
<ul>
{posts.map(post => (
<li key={post.id}>
<a href={`/posts/${post.slug}`}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<time>{new Date(post.createdAt).toLocaleDateString()}</time>
</a>
</li>
))}
</ul>
</div>
);
}
// src/pages/posts/[slug].tsx — dynamic Server Component
export default async function PostPage({ slug }: { slug: string }) {
const post = await db.query.posts.findFirst({
where: eq(posts.slug, slug),
});
if (!post) {
return <div>Post not found</div>;
}
return (
<article>
<h1>{post.title}</h1>
<time>{new Date(post.createdAt).toLocaleDateString()}</time>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
{/* Include interactive Client Component */}
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
);
}Fix 3: Client Components (Interactivity)
// src/components/LikeButton.tsx — Client Component
'use client'; // Required for hooks and interactivity
import { useState } from 'react';
export function LikeButton({ postId, initialLikes }: {
postId: string;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
const [liked, setLiked] = useState(false);
async function handleLike() {
setLiked(!liked);
setLikes(prev => liked ? prev - 1 : prev + 1);
await fetch(`/api/posts/${postId}/like`, {
method: liked ? 'DELETE' : 'POST',
});
}
return (
<button onClick={handleLike}>
{liked ? 'Liked' : 'Like'} {likes}
</button>
);
}
// src/components/SearchBar.tsx — Client Component
'use client';
import { useState, useEffect } from 'react';
export function SearchBar() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (query.length < 2) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
setResults(data.results);
}, 300);
return () => clearTimeout(timer);
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{results.length > 0 && (
<ul>
{results.map(result => (
<li key={result.id}>
<a href={result.url}>{result.title}</a>
</li>
))}
</ul>
)}
</div>
);
}Fix 4: Static vs Dynamic Rendering
// src/entries.tsx
import { createPages } from 'waku';
export default createPages(async ({ createPage, createLayout }) => {
createLayout({
render: 'static',
path: '/',
component: RootLayout,
});
// Static page — pre-rendered at build time
createPage({
render: 'static',
path: '/',
component: HomePage,
});
// Static pages with dynamic paths
const posts = await db.query.posts.findMany();
for (const post of posts) {
createPage({
render: 'static',
path: `/posts/${post.slug}`,
component: PostPage,
});
}
// Dynamic page — rendered on each request (SSR)
createPage({
render: 'dynamic',
path: '/dashboard',
component: DashboardPage,
});
// Dynamic page with params
createPage({
render: 'dynamic',
path: '/users/[id]',
component: UserPage,
});
});Fix 5: API Routes
// Waku doesn't have built-in API routes like Next.js
// Use the server component pattern or a separate API server
// Option 1: Server Actions in Server Components
// src/components/ContactForm.tsx
'use client';
export function ContactForm() {
async function handleSubmit(formData: FormData) {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
}),
});
if (res.ok) {
alert('Message sent!');
}
}
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(new FormData(e.currentTarget)); }}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}Fix 6: Styling
// Waku supports CSS imports and CSS Modules
// Global CSS — import in root layout
// src/components/RootLayout.tsx
import '../styles/global.css';
// CSS Modules
// src/components/Card.module.css
/*
.card { padding: 16px; border-radius: 8px; border: 1px solid #eee; }
.title { font-size: 18px; font-weight: bold; }
*/
import styles from './Card.module.css';
function Card({ title, children }) {
return (
<div className={styles.card}>
<h3 className={styles.title}>{title}</h3>
{children}
</div>
);
}
// Tailwind CSS — configure as usual
// tailwind.config.js content: ['./src/**/*.{ts,tsx}']Still Not Working?
White screen on dev — check that src/entries.tsx exists and exports a default createPages function. This is Waku’s entry point. Without it, no pages are registered and the app renders nothing.
useState throws “not a function” — the component is a Server Component (no 'use client' directive). Add 'use client' at the top of any file that uses hooks, event handlers, or browser APIs.
Dynamic params are undefined — ensure the page path in createPage uses bracket syntax: path: '/posts/[slug]'. The param is passed as a prop to the component: function PostPage({ slug }: { slug: string }).
Build output is empty — static pages are pre-rendered at build time. If createPage calls are inside async conditions that fail, no pages are generated. Check for errors in your data fetching within entries.tsx.
“Cannot import server-only module from a client component” — you have a db, fs, or node:* import inside a file that starts with 'use client'. Move the import into a Server Component (a file without the directive) and pass the resulting data down as props. The boundary is enforced at build time so this never accidentally leaks server code to the browser.
Upgrade from 0.19 to 0.21 broke createPage — Waku is on the 0.x line and minors include breaking changes. Pin the version exactly in package.json ("waku": "0.21.0" not "^0.20.0") and read the release notes on GitHub before bumping. The signature of createPage, the layout render modes, and the supported plugin shape have all shifted across recent minors.
Hydration mismatch with the root layout — the root layout in Waku renders the <html> and <body> tags. If you also include <html> or <body> in a page component or use a Client Component that tries to render at the document level, the server output and client tree disagree and React throws a hydration error. Render document-level tags only in the root layout.
For related React framework issues, see Fix: TanStack Start Not Working, Fix: React Server Components Error, Fix: React Hydration Error, and Fix: Vinxi Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: TanStack Start Not Working — Server Functions Failing, Routes Not Loading, or SSR Errors
How to fix TanStack Start issues — project setup, file-based routing, server functions with createServerFn, data loading, middleware, SSR hydration, and deployment configuration.
Fix: Wasp Not Working — Build Failing, Auth Not Working, or Operations Returning Empty
How to fix Wasp full-stack framework issues — main.wasp configuration, queries and actions, authentication setup, Prisma integration, job scheduling, and deployment.
Fix: Next.js Server Action Not Working — Action Not Called or Returns Error
How to fix Next.js Server Actions — use server directive, form binding, revalidation, error handling, middleware conflicts, and client component limitations.
Fix: Analog Not Working — Routes Not Loading, API Endpoints Failing, or Vite Build Errors
How to fix Analog (Angular meta-framework) issues — file-based routing, API routes with Nitro, content collections, server-side rendering, markdown pages, and deployment.