12k
All articles

Waku: React Server Components Without Next.js

Explore Waku, a lightweight React framework for React Server Components without Next.js. Build a sample app and see how routing, rendering modes, and deployment work.

OpenReplay Team
OpenReplay Team
Waku: React Server Components Without Next.js

Waku is a small React framework built on Vite that runs React 19 server components and server actions. Most of what you need to learn is file-based routing in src/pages plus a getConfig export on each page that picks static or dynamic rendering.

If all your server component work has happened inside the Next.js App Router, it is hard to tell which parts belong to React and which belong to Next. Waku keeps the React model and drops most of the framework around it. This article builds one small app, from scaffold to deploy, so you can decide whether it is worth trying on a side project. The 1.0 line ships as release candidates. v1.0.0-rc.2 was published on 28 September 2026, so check the Waku releases page to see whether a stable 1.0 has followed.

Key Takeaways

  • Waku is a minimal React framework on Vite, with file-based routing in src/pages and a getConfig export per layout and page that returns render: 'static' or render: 'dynamic'.
  • Layouts, pages and slices in Waku render statically by default. A page renders on each request only when its getConfig returns render: 'dynamic'.
  • A static segment route such as src/pages/releases/[slug].tsx has to return a staticPaths array from getConfig.
  • Waku deploys to Node.js by default and also supports pure static output, Vercel, Netlify and Cloudflare Workers. Deno Deploy, Bun and AWS Lambda are marked experimental.
  • Waku 1.0 was in release candidate stage with v1.0.0-rc.2 (28 September 2026), so it is a good fit for small projects where you accept pre-release churn.

How Do You Create a Waku React Project?

To create a Waku React project, run npm create waku@latest. From then on you use three CLI commands: waku dev, waku build and waku start.

npm create waku@latest
# then, inside the project:
npx waku dev     # local dev server
npx waku build   # production build
npx waku start   # serve the production build

The Waku getting-started docs list the supported Node.js versions as ^26.0.0, ^24.0.0 or ^22.15.0. Under the hood, Waku uses Vite’s official @vitejs/plugin-rsc to bundle server components, which is why its config file has a vite key for plugins.

An Async Page That Fetches Data

In Waku, a page is a file in src/pages with a default-exported component. That component can be an async server component that awaits data directly. If you know data fetching with server components from Next.js, the component body will look the same. What changes is the named getConfig export.

To avoid depending on an external API, the example reads a local JSON file. The Waku docs say files in a project-root private folder can be read safely from server components.

// private/releases.json
[
  { "slug": "v2-0", "version": "2.0.0", "summary": "New plugin API." },
  { "slug": "v1-9", "version": "1.9.0", "summary": "Bug fixes." }
]
// src/pages/index.tsx
import { readFile } from 'node:fs/promises';

type Release = { slug: string; version: string; summary: string };

export default async function HomePage() {
  const releases: Release[] = JSON.parse(
    await readFile('./private/releases.json', 'utf8'),
  );

  return (
    <>
      <title>Release notes</title>
      <h1>Release notes</h1>
      <ul>
        {releases.map((release) => (
          <li key={release.slug}>
            <a href={`/releases/${release.slug}`}>{release.version}</a>
          </li>
        ))}
      </ul>
    </>
  );
}

export const getConfig = async () => {
  return { render: 'dynamic' } as const;
};

Because getConfig returns render: 'dynamic', this page is server-rendered on every request. Without that line it would be prerendered at build time. The <title> tag works because Waku hoists title, meta and link tags into the document head.

Adding a Client Component With ‘use client’

Putting 'use client' at the top of a file turns it into a server-client boundary when a server component imports it. From that point down, every component the file pulls in is hydrated and also runs in the browser. This is the same directive and the same rule you already use in the App Router.

// src/components/like-button.tsx
'use client';

import { useState } from 'react';

export const LikeButton = () => {
  const [likes, setLikes] = useState(0);
  return <button onClick={() => setLikes((n) => n + 1)}>👍 {likes}</button>;
};

Client components can’t import server components. Server output can still reach them if you pass it in as children or another prop:

// src/components/collapsible.tsx
'use client';

import { useState, type ReactNode } from 'react';

export const Collapsible = ({ children }: { children: ReactNode }) => {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen((o) => !o)}>{open ? 'Hide' : 'Details'}</button>
      {open && children}
    </div>
  );
};
// src/pages/index.tsx (inside the map, with both components imported)
<li key={release.slug}>
  <a href={`/releases/${release.slug}`}>{release.version}</a>
  <LikeButton />
  <Collapsible>
    <p>{release.summary}</p>
  </Collapsible>
</li>

The summary paragraph renders on the server and is handed to Collapsible as a prop. The client file never imports it. A common confusion with React server components is the role of 'use server'. In the Waku docs, that directive marks server actions, not server components, so it does not belong at the top of a server component file. Client components are still server-rendered to HTML before they hydrate. A session replay of a page like this shows whether the like button responds to the first click or does nothing until hydration finishes.

Routing: Layouts, Dynamic Segments and Render Modes

Waku renders layouts, pages and slices statically unless you say otherwise. API handlers work the other way round and are dynamic by default. You set the rendering mode per file, so a static layout can wrap a dynamic page.

src/
  pages/
    _layout.tsx
    index.tsx
    releases/
      [slug].tsx
  components/
    like-button.tsx
    collapsible.tsx
private/
  releases.json

A _layout.tsx file applies to its own route and to every route nested under it, and its component must take a children prop:

// src/pages/_layout.tsx
import type { ReactNode } from 'react';

export default async function RootLayout({ children }: { children: ReactNode }) {
  return (
    <>
      <header><a href="/">Release notes</a></header>
      <main>{children}</main>
    </>
  );
}

export const getConfig = async () => {
  return { render: 'static' } as const;
};

Bracketed files are segment routes. The segment value arrives as a prop, and you can type it with PageProps from waku/router. When a segment route is static, Waku’s routing docs require getConfig to return a staticPaths array listing the values to prerender. getConfig can be async, so the list can come from data:

// src/pages/releases/[slug].tsx
import { readFile } from 'node:fs/promises';
import type { PageProps } from 'waku/router';

type Release = { slug: string; version: string; summary: string };

const loadReleases = async (): Promise<Release[]> =>
  JSON.parse(await readFile('./private/releases.json', 'utf8'));

export default async function ReleasePage({ slug }: PageProps<'/releases/[slug]'>) {
  const release = (await loadReleases()).find((r) => r.slug === slug);
  if (!release) return <p>Release not found.</p>;
  return (
    <>
      <title>{`Release ${release.version}`}</title>
      <h1>{release.version}</h1>
      <p>{release.summary}</p>
    </>
  );
}

export const getConfig = async () => {
  const releases = await loadReleases();
  return { render: 'static', staticPaths: releases.map((r) => r.slug) } as const;
};

Slices, interceptors and Hono middleware are also available once you need them, but this small app doesn’t use any of them.

Where Can You Deploy Waku?

Waku deploys to Node.js by default. It also supports pure static output, Vercel, Netlify and Cloudflare Workers. The Waku deployment docs mark Deno Deploy, Bun and AWS Lambda as experimental.

  • Node.js: waku start runs the production server. For a standalone copy, ship the dist folder and run node dist/serve-node.js.
  • Pure SSG: upload dist/public to any static host.
  • Vercel: vercel
  • Netlify: NETLIFY=1 npm run build, then netlify deploy
  • Cloudflare Workers: CLOUDFLARE=1 npm run build, then wrangler deploy
  • Deno Deploy, Bun, AWS Lambda (experimental): import waku/adapters/deno, waku/adapters/bun or waku/adapters/aws-lambda in src/waku.server.tsx.

Pure static output drops everything that needs a server at request time: dynamic rendering, server actions and API routes. The dynamic home page above would therefore need render: 'static' before it could deploy as SSG.

When Is Next.js Still the Better Choice?

Next.js is the safer choice if you need a stable release. Waku 1.0 was still a release candidate at v1.0.0-rc.2, and the rc.2 release notes include “breaking: drop deprecated apis”, so expect changes between versions. Next.js also has a much larger ecosystem, more hosting integrations and more tutorials. It also ships more features built in, which you would otherwise assemble yourself. The Waku docs say the choice depends on the architecture you want, not on how big the project is. Waku keeps the framework itself thin and leans on ecosystem libraries for the rest, whereas heavier frameworks take on more of that work themselves. If you want the framework to decide those things for you, stay with Next.js.

Conclusion

Waku gives you the server component model you know from the App Router with far less framework around it: async pages, the same 'use client' boundary, children composition, and a getConfig export that makes the static or dynamic choice explicit for each file. The next step is to run npm create waku@latest, rebuild one small Next.js route with it, and pin the exact RC version in package.json so a future release candidate doesn’t change behaviour under you.

FAQs

How do I add an API route in Waku?

Add a file under src/pages/_api and export a function for each HTTP method the route should handle, such as GET, POST, PUT, PATCH or DELETE. The route comes from the file name. Each handler takes a standard Request and returns a standard Response. API handlers render dynamically by default, and a getConfig returning render 'static' prerenders one at build time, which suits outputs like an RSS feed.

How do environment variables work in Waku?

Server code reads variables with the getEnv function imported from 'waku', and process.env also works in Node.js environments. Client components can only read variables with the WAKU_PUBLIC_ prefix, through import.meta.env, for example import.meta.env.WAKU_PUBLIC_HELLO. Anything with that prefix is shipped as plain text inside the production JavaScript bundle, so never put the WAKU_PUBLIC_ prefix on an API key or other secret.

Should I use anchor tags or the Link component for internal navigation in Waku?

Use the Link component imported from 'waku' for internal links. Its to prop accepts either a route string or a structured object with to, params, search and hash, and navigation then happens on the client through Waku's router. A plain anchor tag does a normal browser navigation instead. For programmatic navigation or reading the current path and query, call the useRouter hook from 'waku' inside a client component.

Are Waku server actions secure by default?

No. A function marked with 'use server' becomes an endpoint that the client can call, and the Waku docs warn that nothing protects these endpoints unless you write authentication and authorization checks inside the function itself. Check the user's identity and permissions in every server action, and add the directive only to functions you intend to expose, so you don't create endpoints you didn't mean to.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.