writing/tutorial/2026/06
TutorialJun 28, 2026·26 min read

Waku: Build a React Server Components App with the Minimal Framework

Learn Waku, the minimal React framework from the creator of Zustand and Jotai. Build a fully working blog with React Server Components, file-based routing, static and dynamic rendering, server functions, and zero-config deployment.

React Server Components (RSC) are now the default mental model for modern React, but most frameworks bury them under layers of conventions, caching rules, and configuration. Waku takes the opposite approach. Created by Daishi Kato, the author of Zustand, Jotai, and Valtio, Waku is "the minimal React framework" — a thin, Vite-powered layer that exposes Server Components, file-based routing, and server functions without the ceremony.

In this tutorial you will build a small but complete blog with Waku: server-rendered post pages, a statically generated index, a client-side interactive component, and a server function that handles a form submission. By the end you will understand exactly where the server/client boundary sits and how to control rendering on a per-route basis.

Why Waku, and When to Reach for It

Waku is not trying to replace Next.js for every team. It occupies a deliberate niche: the smallest viable framework that still gives you the full React Server Components programming model. That positioning has real consequences.

  • The conceptual surface is tiny. There are essentially four things to learn — the src/pages/ conventions, the getConfig render switch, and the 'use client' and 'use server' directives. Once they click, there is no hidden caching layer or routing magic to fight.
  • It is Vite-native. Waku builds on Vite and React 19, so plugins, environment variables, and the dev experience all feel familiar to anyone who has used a modern Vite app. There is no bespoke bundler abstraction in the way.
  • Rendering is an explicit, per-route decision. Instead of inferring static versus dynamic behavior from how you write your data fetching, Waku makes you state it in getConfig. That trade — slightly more boilerplate for far less ambiguity — is the framework's defining choice.

Reach for Waku when you want to learn or teach RSC without distractions, when you are building a content-driven site or a focused app, or when a heavier framework's conventions feel like more than your project needs. Reach for something larger when you need a vast plugin ecosystem, image optimization pipelines, or batteries-included middleware out of the box.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (Waku targets modern Node and uses Vite under the hood)
  • Comfort with React 19 fundamentals: components, props, hooks
  • A basic understanding of what Server Components are (components that render on the server and never ship their JavaScript to the browser)
  • A code editor (VS Code recommended) and a terminal

You do not need prior experience with Next.js or any other meta-framework. In fact, Waku is a great way to learn RSC precisely because it adds so little on top of React.

What You'll Build

A multi-page blog called Waku Notes with:

  • A statically generated home page that lists posts
  • Dynamically rendered individual post pages with a slug-based route
  • A shared layout with navigation
  • A client component for an interactive "like" button
  • A server function that records a comment without a separate API route

The entire data layer is just an in-memory array, so you can focus on the framework rather than a database.

Step 1: Project Setup

Scaffold a new Waku project with the official starter:

npm create waku@latest

The CLI asks for a project name and a template. Choose the basic template and name the project waku-notes. Then install and start the dev server:

cd waku-notes
npm install
npm run dev

Open http://localhost:3000. You should see the Waku starter page. Let's look at the structure the starter created:

waku-notes/
├── src/
│   └── pages/
│       ├── _root.tsx       # Customizes <html>, <head>, <body>
│       ├── _layout.tsx     # Wraps every route (nav, styles, providers)
│       └── index.tsx       # The home page (route: /)
├── public/
├── package.json
└── vite.config.ts

The key idea: everything inside src/pages/ is a Server Component by default. No directive is needed. Data fetching, secrets, and database access all live here and never reach the browser.

Step 2: Understand the Server/Client Boundary

Open src/pages/index.tsx. Because it has no 'use client' directive at the top, it runs on the server. That means you can write async code directly in the component:

// src/pages/index.tsx — a Server Component
export default async function HomePage() {
  const now = new Date().toISOString();
  return (
    <main>
      <h1>Waku Notes</h1>
      <p>Rendered on the server at {now}</p>
    </main>
  );
}

There is no getServerSideProps, no loader, no special data API. The component is the data fetcher. Anything you await here happens on the server, and only the resulting HTML and a serialized RSC payload reach the client.

A Banner tip worth internalizing early:

Server Components can import and render Client Components, but never the other way around in the same module. A Client Component can only receive serializable props (strings, numbers, plain objects, and other Server-rendered children) from a Server Component.

Step 3: Create the Data Layer

Create a tiny data module that both server pages will share. Make src/lib/posts.ts:

// src/lib/posts.ts
export type Post = {
  slug: string;
  title: string;
  excerpt: string;
  body: string;
  publishedAt: string;
};
 
const posts: Post[] = [
  {
    slug: "hello-waku",
    title: "Hello, Waku",
    excerpt: "Why a minimal React framework matters in 2026.",
    body: "Waku keeps the surface area small so React Server Components stay legible.",
    publishedAt: "2026-06-20",
  },
  {
    slug: "rendering-modes",
    title: "Static vs Dynamic Rendering",
    excerpt: "Choose the right render mode per route.",
    body: "Pages are static by default; opt into dynamic when you need per-request data.",
    publishedAt: "2026-06-24",
  },
];
 
export async function getAllPosts(): Promise<Post[]> {
  return posts;
}
 
export async function getPost(slug: string): Promise<Post | undefined> {
  return posts.find((p) => p.slug === slug);
}

In a real app these functions would query Postgres, call an API, or read MDX files. The signature stays the same because Server Components just await them.

Step 4: Build the Static Home Page

Replace src/pages/index.tsx with a list of posts. Notice the exported getConfig function — this is how Waku decides how to render a route:

// src/pages/index.tsx
import { Link } from "waku";
import { getAllPosts } from "../lib/posts";
 
export default async function HomePage() {
  const posts = await getAllPosts();
  return (
    <main>
      <h1>Waku Notes</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.slug}>
            <Link to={`/blog/${post.slug}`}>{post.title}</Link>
            <p>{post.excerpt}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}
 
export const getConfig = async () => {
  return {
    render: "static",
  } as const;
};

Two things to highlight:

  1. Link is imported from waku. It performs client-side navigation without a full page reload, fetching the RSC payload of the destination route.
  2. getConfig returns render: 'static'. At build time, Waku prerenders this page to plain HTML. It becomes a static asset you can host anywhere — no server required for this route.

Pages are static by default, so technically you could omit getConfig here. Declaring it explicitly makes intent obvious and is good practice in a shared codebase.

Step 5: Add a Shared Layout

Open src/pages/_layout.tsx. A _layout.tsx file wraps its route and all descendant routes. The root layout is the right place for global navigation and shared styling:

// src/pages/_layout.tsx
import "../styles.css";
import { Link } from "waku";
import type { ReactNode } from "react";
 
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <div className="app">
      <header>
        <nav>
          <Link to="/">Home</Link>
          <Link to="/about">About</Link>
        </nav>
      </header>
      <section>{children}</section>
      <footer>
        <p>Built with Waku — the minimal React framework</p>
      </footer>
    </div>
  );
}

Layouts are Server Components too. They render once on the server and stay mounted across client-side navigations within their segment, so the header does not flash or remount when you move between pages.

Step 6: Create a Dynamic Route for Posts

Now the interesting part. Create src/pages/blog/[slug].tsx. The square brackets make slug a dynamic segment, and Waku passes its value as a prop:

// src/pages/blog/[slug].tsx
import { getAllPosts, getPost } from "../../lib/posts";
 
type PostPageProps = { slug: string };
 
export default async function PostPage({ slug }: PostPageProps) {
  const post = await getPost(slug);
  if (!post) {
    return <p>Post not found.</p>;
  }
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.publishedAt}</time>
      <p>{post.body}</p>
    </article>
  );
}
 
export const getConfig = async () => {
  const posts = await getAllPosts();
  return {
    render: "static",
    staticPaths: posts.map((p) => p.slug),
  } as const;
};

Here getConfig returns render: 'static' plus a staticPaths array. Waku calls your data function at build time, learns there are two posts, and prerenders /blog/hello-waku and /blog/rendering-modes as static HTML. This is the equivalent of "static site generation with dynamic paths" in other frameworks, but expressed as one small config function colocated with the page.

If you instead want the page rendered fresh on every request — say the body comes from a CMS that updates constantly — switch the mode:

export const getConfig = async () => {
  return {
    render: "dynamic", // SSR: runs on the server for each request
  } as const;
};

With dynamic, you drop staticPaths entirely because paths are resolved at request time. This per-route choice is Waku's core ergonomic win: rendering strategy is a one-line decision, made next to the component that needs it.

Step 7: Add a Client Component

So far everything has been server-rendered. Interactivity — state, effects, event handlers — requires a Client Component. Create src/components/LikeButton.tsx and mark it with 'use client':

// src/components/LikeButton.tsx
"use client";
 
import { useState } from "react";
 
export function LikeButton({ initial = 0 }: { initial?: number }) {
  const [likes, setLikes] = useState(initial);
  return (
    <button onClick={() => setLikes((n) => n + 1)}>
{likes}
    </button>
  );
}

The 'use client' directive marks the boundary: this module and its dependencies are bundled and shipped to the browser. Everything above it in the tree stays server-only.

Now render it from the Server Component post page. The Server Component passes a plain number as a prop across the boundary:

// src/pages/blog/[slug].tsx (excerpt)
import { LikeButton } from "../../components/LikeButton";
 
// ...inside PostPage, after the <p>{post.body}</p>:
<LikeButton initial={0} />

Reload a post page and click the button — the counter updates instantly in the browser, while the surrounding article remains server-rendered. You have a precise, minimal hydration island.

Step 8: Handle a Form with a Server Function

Waku supports server functions (the 'use server' directive), which let a Client Component call server-side code directly — no manual API route, no fetch boilerplate. Create src/lib/comments.ts:

// src/lib/comments.ts
"use server";
 
const comments: { slug: string; text: string }[] = [];
 
export async function addComment(slug: string, text: string) {
  if (!text.trim()) {
    return { ok: false, error: "Comment cannot be empty" };
  }
  comments.push({ slug, text });
  return { ok: true, count: comments.filter((c) => c.slug === slug).length };
}

The 'use server' directive at the top of the file means every exported function runs on the server, even when invoked from the browser. Waku generates the RPC plumbing for you. Now call it from a Client Component:

// src/components/CommentBox.tsx
"use client";
 
import { useState } from "react";
import { addComment } from "../lib/comments";
 
export function CommentBox({ slug }: { slug: string }) {
  const [text, setText] = useState("");
  const [status, setStatus] = useState("");
 
  async function submit() {
    const result = await addComment(slug, text);
    if (result.ok) {
      setStatus(`Saved. Total comments: ${result.count}`);
      setText("");
    } else {
      setStatus(result.error ?? "Something went wrong");
    }
  }
 
  return (
    <div>
      <textarea value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={submit}>Post comment</button>
      <p>{status}</p>
    </div>
  );
}

The import looks completely ordinary, but addComment never runs in the browser. The argument is serialized, sent to the server, executed there, and the return value comes back — all type-checked end to end because it is plain TypeScript. Drop <CommentBox slug={post.slug} /> into the post page below the like button.

Step 9: Programmatic Routing (Optional, Advanced)

File-based routing covers most apps, but Waku also exposes a lower-level createPages API for when routes are generated from data or you want full control. Create src/waku.server.tsx:

// src/waku.server.tsx
import { createPages } from "waku";
import adapter from "waku/adapters/default";
import { HomePage } from "./templates/home-page";
import { AboutPage } from "./templates/about-page";
 
const pages = createPages(async ({ createPage }) => [
  createPage({
    render: "static",
    path: "/",
    component: HomePage,
  }),
  createPage({
    render: "dynamic",
    path: "/about",
    component: AboutPage,
  }),
]);
 
export default adapter(pages);

createPage takes the same render, path, and component you already know from file-based routing — it just lets you build the route table programmatically. There are companion methods: createLayout for wrappers, createRoot to customize the document shell, and createApi for raw request handlers. Reach for this only when file conventions become limiting; for the blog, the file-based approach is cleaner.

Step 10: Build and Deploy

Produce a production build:

npm run build

Waku prerenders every render: 'static' route to HTML, bundles client islands, and prepares a server entry for any render: 'dynamic' routes. Preview locally:

npm run start

Waku is deployment-agnostic and ships adapters for the major platforms. To target Vercel, for example, set the deploy environment before building:

npm run build -- --with-vercel

Supported targets include Node.js, Vercel, Netlify, Cloudflare Workers, Deno Deploy, Bun, and AWS Lambda. A fully static blog (every route render: 'static') can even be dropped onto any CDN or object storage, because the build output is just HTML and assets.

MENA deployment note: If your audience is in Tunisia, the Gulf, or wider MENA, prefer an edge target (Cloudflare Workers) or a region close to your users. Static routes served from a CDN sidestep round-trip latency entirely, which matters on uneven mobile connections.

Testing Your Implementation

Verify the build behaves as expected:

  1. Run npm run build and confirm the output lists prerendered HTML for /, /blog/hello-waku, and /blog/rendering-modes.
  2. Run npm run start, then disable JavaScript in your browser dev tools and reload a static post page — the content should still appear, proving it was server-rendered.
  3. Re-enable JavaScript and click the like button — the counter should increment without a network request, proving the client island hydrated.
  4. Submit a comment and confirm the returned count increases, proving the server function executed remotely.

Troubleshooting

"Hooks can only be called inside a Client Component." You used useState, useEffect, or an event handler in a file without 'use server' and without 'use client'. Add 'use client' to the top of that component file.

A Client Component renders a Server Component and breaks. Server Components cannot be imported into Client Components. Instead, pass the Server Component as children from a parent Server Component.

staticPaths route returns 404. The slug requested is not in the array your getConfig returned at build time. For frequently changing content, switch the route to render: 'dynamic'.

Server function throws "cannot be called on the server during render." Server functions are meant to be invoked from Client Component event handlers, not during a Server Component's render pass. For data needed during render, call your data module directly instead.

Next Steps

  • Replace the in-memory array with a real database. Pair this with our Drizzle ORM Next.js guide — the data-layer patterns transfer directly to Waku Server Components.
  • Add metadata and SEO tags through _root.tsx for per-route titles and Open Graph data.
  • Explore Waku Slices for fine-grained partial rendering of shared UI fragments.
  • Compare meta-framework approaches with our TanStack Start vs Next.js tutorial to see where a minimal RSC framework fits your stack.

Conclusion

Waku proves that React Server Components do not require a heavy framework. With one directory of conventions, an exported getConfig for per-route rendering, the 'use client' and 'use server' boundaries, and a handful of deploy adapters, you have everything needed to ship a fast, server-first React app. You built a blog with static and dynamic routes, a hydrated client island, and a server function — and at every step the server/client boundary stayed explicit and legible. That clarity is Waku's whole point: keep the framework minimal so your React stays the star.