### Community/Examples # Community Work - [Waku with yarn pnp](https://github.com/bysxx/waku-with-yarn-pnp) @bysxx - [Waku with TailwindCSS v4 & Shadcn/ui & SSR pre-hydrated theme logic](https://github.com/JesseKoldewijn/waku-tw4-shadcn-starter) @JesseKoldewijn --- ### Community/Waku En Espanol --- slug: waku-en-espanol title: Learn Waku in Spanish description: Comprehensive guide to learn Waku in Spanish, with real code examples and best practices author: Ariel GonzAgΓΌer date: 2026-01-25 tags: [guide, spanish, tutorial, learning] --- # Learn Waku in Spanish A comprehensive Spanish-language guide for learning Waku, the minimal React framework. This resource is based on the official documentation and includes practical examples, best practices, and real-world code samples. ## Resource Visit the guide at: [aprenderwaku.netlify.app](https://aprenderwaku.netlify.app/) ## What's Included This Spanish learning resource covers: - **Complete Waku fundamentals** - Introduction to the minimal React framework - **React Server Components (RSC)** - Understanding server and client components - **File-based routing** - Pages, layouts, and navigation patterns - **Data fetching** - Server-side and client-side data patterns - **Server actions** - Mutations - **Real code examples** - Practical implementations you can use - **Best practices** - Production-ready patterns and recommendations - **Deployment guides** - Deploying to various platforms ## Who Is This For? This guide is perfect for: - Spanish-speaking developers learning Waku - React developers transitioning to server components - Anyone looking for practical Waku examples in Spanish - Teams working with Waku in Spanish-speaking regions ## Getting Started The guide follows the official documentation, starting from basic concepts and progressing to advanced topics. Each section includes: - Clear explanations in Spanish - Code examples with comments - Common patterns and use cases - Tips and best practices ## Why This Resource? - **Native Spanish content** - Not just a translation, but content written for Spanish speakers - **Additional context** - Extra explanations and examples - **Community perspective** - Real-world experiences and solutions - **Accessibility** - Making Waku accessible to the Spanish-speaking developer community ## Links - [Visit the Guide](https://aprenderwaku.netlify.app/) - [Waku Official Docs](https://waku.gg/) - [Waku GitHub](https://github.com/wakujs/waku) --- _This is a community-created resource and is not officially maintained by the Waku team, but it follows the official documentation and best practices._ --- ### Guides/Getting Started/Comparison --- slug: comparison title: Comparison description: How Waku compares architecturally with other frameworks. category: Getting Started order: 40 --- ## How to read this page Frameworks are best compared by their durable architectural choices, not by feature checklists that change every release. All of the frameworks below are excellent at what they are designed for; the question is which model fits your project. Details reviewed in July 2026 against each framework's documentation. Always check the linked docs for the current state. ## At a glance | | Waku | [Next.js](https://nextjs.org/docs) | [Astro](https://docs.astro.build) | [React Router](https://reactrouter.com) | [TanStack Start](https://tanstack.com/start/latest) | | --------------------------------- | ---- | ---------------------------------- | --------------------------------- | --------------------------------------- | --------------------------------------------------- | | React Server Components | βœ… | βœ… | βž– | πŸ§ͺ | πŸ§ͺ | | Static pages with dynamic regions | βœ… | βœ… | βœ… | βž– | βž– | | Framework-managed caching | βž– | βœ… | βž– | βž– | βž– | | UI libraries beyond React | βž– | βž– | βœ… | βž– | βž– | | Deployment adapters | βœ… | πŸ§ͺ | βœ… | βœ… | βœ… | βœ… supported, πŸ§ͺ experimental, βž– not part of the design. React Router is compared in its framework mode; TanStack Start is powered by [TanStack Router](https://tanstack.com/router/latest). The sections below carry the substance the checkmarks cannot. ## Next.js Next.js optimizes for an integrated application platform: the broadest feature set in the React ecosystem, with the framework owning much of the application lifecycle, including a caching and revalidation model that controls rendering from whole routes down to individual functions. Waku optimizes for the opposite trade: a small framework-owned surface with direct execution semantics, no implicit cache to reason about, and ecosystem libraries for the concerns the framework doesn't own. If you want an integrated platform, Next.js is a strong choice; if you prefer to keep more of those decisions in your own hands, Waku may be the better fit. ## Astro Astro is optimized for content-first, multi-framework sites: static-first delivery, minimal browser JavaScript, content collections for local data, and islands from any UI library. Its server islands overlap conceptually with Waku's slices. The difference is that Waku is React-native throughout: server components, client components, layouts, pages, and slices belong to one React mental model, so a site that starts mostly static can grow meaningful server-driven application behavior without changing its UI model or adding a second component format. ## React Router React Router is built for progressive adoption (declarative, data, and framework modes) with mature loader/action conventions, per-route SSR, prerendering, and SPA modes, and a web-standards request/response model. Its React Server Components support is currently experimental. The architectural distinction: React Router begins with routing and layers framework behavior on top; Waku begins with the React server-component model and supplies the routing needed to compose it. Data flows through route loaders there, and through component composition here. ## TanStack Start TanStack Start, powered by TanStack Router, offers end-to-end type-safe routing, server functions, full-document SSR with streaming, and an explicit, router-centered programming model across multiple deployment targets. It shares Waku's taste for explicitness and portability. The distinction is which abstraction sits at the center: if you want an elaborate type-safe router and server-function model as the dominant abstraction, TanStack may fit you better. Waku is the stronger fit when the abstraction you want is React itself: server components as the architecture, with routing in a supporting role. ## Next Step [Use Cases](/guides/use-cases) describes the application shapes Waku fits best, and the ones where another framework is the better call. --- ### Guides/Getting Started/Introduction --- slug: introduction title: What is Waku? description: The minimal React framework built around React Server Components. category: Getting Started order: 20 --- ## The minimal React framework **Waku** _(wah-ku)_ or **わく** means "frame" in Japanese, as in framework. It is the minimal React framework, built around React Server Components and server actions. Minimal is not just about size. It means fewer hidden execution semantics: you can read a Waku route file and predict when its server code runs. Pages are prerendered at build time by default, pages you declare dynamic execute on every request, and Waku does not place an implicit cache in front of your rendering or data fetching. ## What you get - **File-based routing.** Files in `src/pages` become routes. Layouts, dynamic segments, catch-all routes, and API routes are all part of the same convention. - **Static and dynamic rendering in one app.** Each page, layout, and slice (an independently rendered fragment of a page) declares its own rendering mode, so a prerendered marketing page and a per-request dashboard live side by side. - **Server and client components.** Fetch data with `await` directly in server components. Add `'use client'` where you need interactivity. - **All React.** Waku adds no parallel programming model or component format. Skills learned in Waku are React skills, and React patterns from elsewhere work in Waku. - **Deployment adapters.** The same application deploys to Node.js, Vercel, Netlify, Cloudflare, AWS Lambda, Deno, or Bun without changing how you write React. ## A route at a glance ```tsx // ./src/pages/index.tsx export default async function HomePage() { const posts = await getPosts(); return ( ); } ``` This is a complete Waku page: a server component that fetches its own data. It is prerendered at build time by default; adding a `getConfig` export with `render: 'dynamic'` makes it execute on every request instead. ## Next Step Curious why Waku is designed this way? Read the [Philosophy](/guides/philosophy). --- ### Guides/Getting Started/Philosophy --- slug: philosophy title: Philosophy description: The principles behind Waku's design. category: Getting Started order: 30 --- ## Minimal is the feature Waku keeps its API surface small enough to hold in your head: files in `src/pages` become routes, `getConfig` declares how a route renders, `'use client'` marks the interactive boundary, and server actions handle mutations. Everything else is React. A small surface is not a limitation. It means less framework-specific knowledge between you and your application, and fewer places where behavior needs explaining. ## Explicit over implicit Waku's rendering model is a set of declarations, not heuristics: - **Static is the default.** Pages and layouts are prerendered at build time unless you declare them dynamic. Static output is a build artifact: it stays the same until the next build replaces it. A rebuild is not "cache invalidation"; it is producing a new artifact. - **Dynamic means every request.** A route declared `render: 'dynamic'` executes on each request and sees current data. - **No implicit caching.** Waku does not put a cache in front of dynamic rendering or data fetching. What you fetch is what you render. There is no framework revalidation model to learn, because there is nothing to invalidate until you deliberately add a cache yourself. In short: **static at build time, fresh at request time, cache explicitly.** The result is a simple render model. Code runs in one of three places, and every piece of the app clearly belongs to one of them: 1. **At build time.** Static pages and layouts execute once during the production build. 2. **On the server, per request.** Dynamic pages and layouts, server actions, and API routes. 3. **In the browser.** Client components (which also render once on the server to produce the initial HTML). Because the model is small, you rarely have to ask "when does this code actually run?" The answer is in the file you are looking at. ## Extensible without compiler magic Waku's capabilities grow through libraries, not compiler plugins. The compilers in a Waku app belong to React and Vite; Waku itself avoids adding compile-time magic, so extending it means writing ordinary code. The best evidence is Waku itself: `waku/router`, with its pages, layouts, and file conventions, is a library built on top of the Waku core. Extensions can take the same shape at any scale: a published package, a private shared library, or a module inside your repository. Because they are ordinary code, they compose, version, and debug like ordinary code. ## Next Step See how these choices play out against other frameworks in the [Comparison](/guides/comparison). --- ### Guides/Getting Started/Quick Start --- slug: quick-start title: Quick Start description: Scaffold a new Waku project and run it locally in five minutes. category: Getting Started order: 10 --- ## Prerequisites - [Node.js](https://nodejs.org) `^26.0.0`, `^24.0.0`, or `^22.15.0` - Any package manager (the commands below use `npm`) ## Scaffold a project Run the following command in your terminal: ```sh npm create waku@latest ``` Follow the CLI prompts. The default project name is `waku-project`. Then install dependencies and start the development server: ```sh cd waku-project npm install npm run dev ``` Open [http://localhost:3000](http://localhost:3000) in your browser. You should see a small demo app with an interactive counter. ## Project structure The starter contains the following files: ```text waku-project/ β”œβ”€β”€ public/ # static assets served as-is β”œβ”€β”€ src/ β”‚ β”œβ”€β”€ components/ # reusable React components β”‚ β”œβ”€β”€ middleware/ # optional server middleware β”‚ β”œβ”€β”€ pages/ # file-based routes β”‚ β”‚ β”œβ”€β”€ _layout.tsx β”‚ β”‚ β”œβ”€β”€ about.tsx β”‚ β”‚ └── index.tsx β”‚ β”œβ”€β”€ global.d.ts β”‚ └── styles.css β”œβ”€β”€ package.json β”œβ”€β”€ tsconfig.json └── waku.config.ts ``` - `src/pages` is the router: each file becomes a route, and `_layout.tsx` wraps the pages next to and below it. - `src/components` holds regular React components that pages import. - `src/middleware` contains optional [Hono](https://hono.dev) middleware that the default setup picks up automatically. The starter ships one that removes trailing slashes. - `public` files are copied to the site root unchanged, such as images and fonts. - `waku.config.ts` configures Waku and Vite. The starter enables Tailwind CSS and React Compiler plugins. While the development server is running, Waku also generates `src/pages.gen.ts` containing route types for type-safe links. It is regenerated automatically whenever your pages change, so you never edit it by hand. ## Make your first edit Open `src/pages/index.tsx`: ```tsx // ./src/pages/index.tsx (excerpt) export default async function HomePage() { const data = await getData(); return (
{data.title}

{data.headline}

{data.body}

About page
); } ``` The content comes from a `getData` function defined below the component. Change its `body` text and save. The browser updates instantly without a full reload. ## Commands - `npm run dev` starts the development server at [http://localhost:3000](http://localhost:3000) - `npm run build` creates a production build in `dist` - `npm run start` serves the production build at [http://localhost:8080](http://localhost:8080) ## Next Step Read [What is Waku?](/guides/introduction) for a tour of what the framework offers. --- ### Guides/Getting Started/Use Cases --- slug: use-cases title: Use Cases description: Where Waku fits well, and where another framework may serve you better. category: Getting Started order: 50 --- ## What fit means here Whether Waku fits is a question about the architecture you want (how much of the application the framework should own), not about the size of your company or your traffic. Waku's rendering granularity is per page, per layout, and per slice, so the shapes below are about how much of your UI is stable versus request-dependent, and who you want managing the difference. ## Strong fits - **Content, marketing, documentation, and editorial sites** with some dynamic or personalized surfaces. The stable majority is prerendered at build time; the dynamic parts execute per request. - **Headless commerce.** Catalog and editorial pages are static; inventory, pricing, cart, account, and recommendation surfaces are dynamic and always fresh, with no cache invalidation choreography between your commerce backend and your frontend framework. - **Full-stack React products** that want React Server Components and server actions without adopting a framework-owned data lifecycle. You choose your database client, auth approach, and (if ever needed) caching. - **Teams that value deployment portability.** Agencies shipping to whatever host each client uses, and products that don't want architecture coupled to a platform. - **Framework and platform authors** who need a thin React Server Components substrate to build on, rather than a competing opinion stack. ## Weaker fits Waku is intentionally not everything. Consider a more batteries-included framework if: - You want the framework to own **integrated solutions** for authentication, image processing, internationalization, analytics, and similar concerns, rather than assembling ecosystem libraries. - Your application's core requirement is a **framework-managed distributed cache and revalidation platform**. Waku treats caching as an explicit, application-owned optimization, not a built-in subsystem. - You want a **single vendor's platform** to manage the entire application lifecycle from framework through hosting. None of these are failures of scale; Waku serves small sites and large ones. They are differences in how much framework you want. ## Next Step Ready to build? The Learn series begins with [The Mental Model](/guides/concept). --- ### Guides/Integrations/Explicit Caching --- slug: explicit-caching title: Explicit Caching description: Why caching is outside Waku's core, and waku-cache as one solution. category: Integrations order: 10 --- ## Rendering in Waku Waku's rendering model has two modes and no hidden cache: | Rendering | When server code runs | Freshness | Invalidation | | --------- | --------------------- | -------------------------- | --------------------------------------- | | Static | At build time | Fixed until the next build | None; a rebuild produces a new artifact | | Dynamic | On every request | Fresh by default | None needed; nothing is cached | Static pages and layouts are prerendered into build artifacts that a deploy replaces wholesale. Dynamic ones execute on every request, and what you fetch is what you render. Waku does not place a cache in front of either, so there is no framework revalidation model to learn: when your data changes, the next request sees it. The [Learn series](/guides/static-and-dynamic-rendering) covers this model hands-on. ## Caching is outside the core That is a deliberate boundary, not a missing feature. A built-in cache would give the framework its own data lifecycle, with keys, lifetimes, and invalidation rules that every app inherits whether it needs them or not. Waku keeps the core minimal and its execution semantics visible (see [Philosophy](/guides/philosophy)), and leaves caching to ordinary libraries, the same way `waku/router` itself is a library on top of the Waku core. Practically, this means: 1. Correctness never depends on a cache. Start without one. 2. Measure before caching. Look for repeated, expensive work: hot database queries, slow upstream APIs, costly renders. 3. Choose the tool that fits: HTTP and CDN caching for whole responses, memoization you write yourself, or a caching library. Whatever you pick, you own its policy, and removing it changes performance, never correctness. ## waku-cache, one solution [`waku-cache`](https://github.com/wakujs/waku-cache) is one library that fills this space: a separate, still-evolving package that caches exactly what you wrap and nothing else. It offers two primitives, caching a function and caching an RSC subtree, over a small swappable storage interface. Create one cache instance for your app: ```ts // ./src/lib/cache.ts import { createCache } from 'waku-cache'; import { memoryStore } from 'waku-cache/stores/memory'; export const cache = createCache({ store: memoryStore(), defaults: { ttl: 60_000 }, }); ``` The memory store is process-local, so a multi-instance deployment needs a shared store instead. ### Cache a function Wrap an async function; the call signature is unchanged, so it is a drop-in: ```ts import { cache } from './lib/cache.js'; const getProduct = cache.fn( async (id: string) => db.products.findUnique({ where: { id } }), { key: (id) => ['product', id], ttl: 5 * 60_000, }, ); const product = await getProduct('abc'); // identical to the unwrapped call ``` The key is a function of the arguments, and concurrent misses for the same key are single-flighted within a process. ### Cache an RSC subtree Wrap a server component; its rendered subtree is serialized once and replayed on later requests: ```tsx import { cache } from '../lib/cache.js'; const ProductCard = async ({ id }: { id: string }) => { const product = await getProduct(id); return
{product.name}
; }; const { Component, getEtag } = cache.rsc(ProductCard, { key: ({ id }) => ['product', id, 'card'], ttl: 60_000, }); export default Component; ``` The returned `getEtag` is optional; the section below explains what it is for. ### Keys and invalidation Keys are arrays of parts, and invalidation matches by prefix, so one call can drop a whole group of entries: ```ts await cache.invalidate({ key: ['product', 'abc'] }); // one product and its card await cache.invalidate({ key: ['product'] }); // every product entry ``` A common place to call `invalidate` is wherever you handle a mutation, such as a server action or an API route. This is the only invalidation in a Waku app, and it exists only because you added the cache. Include every value that can affect the output in the cache key: the user, tenant, locale, permissions, and anything else read from request context rather than from arguments or props. The cache returns entries purely by key, so a missing dimension replays one request's output to another, and a process-local store does not make this safe. Do not cache request-specific or sensitive subtrees unless the key safely scopes them. The same applies to `cache.fn` when the wrapped function depends on context that is not part of its arguments. ## Etags: skipping unchanged payloads The client keeps a small cache of etags for the elements it currently holds. Each slot in an RSC response (a page, a layout, a slice) can carry an etag; the client remembers them and sends them back with the next navigation fetch in the `X-Waku-Etags` header. The server compares them and omits any slot whose etag still matches, so the response carries only what changed, and the client keeps the elements it already has for the omitted slots. Static slots are marked immutable, which is how the router knows it can always reuse them. Dynamic slots have no etag by default, so they are always re-sent: without one, the server cannot know whether the content changed. A dynamic route can opt in through `getConfig`'s `unstable_getEtag`, and this is where waku-cache fits: `cache.rsc` returns a `getEtag` that hashes the cached subtree's serialized bytes. The tag stays stable while the cached bytes are unchanged; after invalidation or TTL expiry the entry is regenerated, and the tag changes only when the serialized content changes: ```tsx export const getConfig = () => { return { render: 'dynamic', unstable_getEtag: getEtag, // delete this line and the render cache still works } as const; }; ``` The two savings compose: `cache.rsc` saves the server from re-rendering the subtree, and the etag saves the network from re-sending it. See the [waku-cache README](https://github.com/wakujs/waku-cache#readme) for the details. ## What stays uncached During client-side navigation, the router reuses prefetched RSC payloads so that moving between pages is fast. This is a browser-side navigation optimization: it does not create a server data cache. Static payloads can be reused freely because they change only with a deploy. An explicitly prefetched dynamic response may also be reused until its prefetch TTL expires (60 seconds by default), so a navigation shortly after a prefetch may not issue a new request; whenever a request does reach the server, it renders fresh. See [Navigation and Prefetching](/guides/navigation-prefetching) for the details and the experimental tuning APIs. Beyond the application, other caches exist with their own controls: the browser cache and CDNs (driven by HTTP headers), static build output (typically served with long-lived caching by hosts), and database drivers or API SDKs that cache internally. Waku does not control these caches; they can affect whether a request reaches your server at all, and what data your dependencies return when it does. ## Common misconceptions - **"Dynamic means uncached."** Dynamic means uncached _by Waku_. You can still cache expensive work explicitly, and infrastructure layers can cache responses if you tell them to. - **"Static output is a cache."** Static output is a build artifact. It has no keys, no TTL, and no invalidation API; it is replaced wholesale by the next build. - **"Client navigation reuse makes server data stale."** It only affects what the browser shows during navigation; the server's answer to any request it receives is computed fresh. - **"Server components run once."** Static server components execute at build time; dynamic server components execute on every request. Neither runs in the browser. - **"Every framework needs a revalidation model."** An invalidation responsibility appears when a cache is added. Waku without an explicit cache has nothing to invalidate. --- ### Guides/Integrations/State Across Client Server --- slug: state-across-client-server title: State Across Client and Server description: Share selected client-module values with server code using unstable_allowServer. category: Integrations order: 30 tags: [Experimental] --- ## When to Use `unstable_allowServer` Sometimes a value must be declared in a client module, but server code still needs to import that one value. `unstable_allowServer` marks a specific export from a `'use client'` module as safe for Waku's server build. Use it for shared definitions, not live state: - Jotai atom definitions that must be colocated with a client component - small constants used by both server and client code - pure factory results that do not touch browser APIs Do not use it for: - React components - hooks - functions that read `window`, `document`, `localStorage`, or browser-only globals - mutable singletons that should be request-scoped - secrets, database clients, or server-only resources - a shortcut to import an entire client module from server code The API is highly experimental and may change. ## Colocated Jotai Atom This example follows the pattern used by the Waku Jotai examples. The atom is declared in the same client module as the component, and only the atom export is marked as server-safe. ```tsx // src/components/counter.tsx 'use client'; import { useTransition } from 'react'; import { atom, useAtom } from 'jotai'; import { unstable_allowServer as allowServer } from 'waku/client'; export const countAtom = allowServer(atom(1)); export const Counter = () => { const [count, setCount] = useAtom(countAtom); const [isPending, startTransition] = useTransition(); const increment = () => { startTransition(() => { setCount((count) => count + 1); }); }; return ( ); }; ``` Because `countAtom` is wrapped with `allowServer(...)`, server code can import that one export from the client module and use it with the Jotai store: ```tsx // src/pages/index.tsx import { getStore } from 'waku-jotai/router'; import { Counter, countAtom } from '../components/counter'; export default async function Page() { const store = await getStore(); return ( <>

Initial count: {store.get(countAtom)}

); } export const getConfig = async () => { return { render: 'dynamic', } as const; }; ``` For file-system-router apps, wrap the route tree with `RouterProvider`: ```tsx // src/pages/_layout.tsx import type { ReactNode } from 'react'; import { RouterProvider } from 'waku-jotai/router'; export default function RootLayout({ children }: { children: ReactNode }) { return {children}; } export const getConfig = async () => { return { render: 'dynamic', } as const; }; ``` Every page or layout that reads `getStore()` or mounts `RouterProvider` must render dynamically: the store is request-scoped and the atom values arrive with each RSC request, so a static render would bake in build-time values. If you use the Minimal API, use `Provider` and `getStore` from `waku-jotai/minimal`. Unlike `RouterProvider`, this `Provider` needs the RSC request identity as props: ```tsx {children} ``` ## Prefer a Neutral Module When Possible If the shared definition does not need to live in a client module, put it in a module without a `'use client'` directive. React Server Components can import ordinary server-safe modules without `unstable_allowServer`. For example, a Jotai atom definition can often live in a shared module: ```ts // src/state/count.ts import { atom } from 'jotai/vanilla'; export const countAtom = atom(1); ``` Then client components can import that shared value from a client module: ```tsx // src/components/counter.tsx 'use client'; import { useAtom } from 'jotai'; import { countAtom } from '../state/count'; export const Counter = () => { const [count, setCount] = useAtom(countAtom); return ( ); }; ``` Server code can also import `countAtom` from `src/state/count.ts` if it has a real server-side use for the atom definition. Use this pattern when you can because it keeps the client/server boundary obvious. ## What Waku Transforms `unstable_allowServer` is an identity function at runtime. Its main purpose is to tell Waku's Vite plugin which expression from a client module should remain importable in the RSC build. In the RSC environment, Waku transforms a `'use client'` module so that: - the wrapped `allowServer(...)` export is preserved - dependencies used by that wrapped expression are preserved - other exports are not made callable server functions - the wrapper call itself is removed from the emitted server-side expression This means the server can import `countAtom`, but it does not make every export in `counter.tsx` server-safe. `unstable_allowServer` must receive exactly one argument: ```tsx export const countAtom = allowServer(atom(1)); ``` ## What It Does Not Do `unstable_allowServer` itself only gives the atom a shared, server-safe identity. When the waku-jotai provider is mounted, wrapped atom values are synchronized from the client to the server during RSC refetches: the client subscribes to the allowed atoms and refetches the route with their current values, which the server reads through the request-scoped store. The synchronization is one way; the server does not independently push atom updates back to the browser. It also does not make browser-only code safe on the server. This is unsafe: ```tsx 'use client'; import { unstable_allowServer as allowServer } from 'waku/client'; export const theme = allowServer(localStorage.getItem('theme')); ``` The server has no `localStorage`, so this module cannot be evaluated safely in the RSC environment. ## Safer Alternatives Before using `unstable_allowServer`, consider these alternatives: - Move shared definitions to a neutral module without `'use client'`. - Pass serializable values from server components to client components as props. - Put request-specific data in request context or provider state. - Keep browser-only behavior inside client components and hooks. - Use provider boundaries to initialize client state from server-rendered values. Use `unstable_allowServer` only for the narrow case where a client module must expose one server-safe definition and moving that definition to a neutral module is worse for the integration. --- ### Guides/Learn/Building For Production --- slug: building-for-production title: Building for Production description: Build the app, inspect what got prerendered, and watch the rendering model hold. category: Learn order: 50 --- ## Build Stop the dev server and run: ```sh npm run build ``` The build bundles the client and server code, then executes every static page and layout to prerender them. The result lands in `dist`: ```text dist/ β”œβ”€β”€ public/ # served as static files β”‚ β”œβ”€β”€ index.html β”‚ β”œβ”€β”€ about/index.html β”‚ β”œβ”€β”€ blog/index.html β”‚ β”œβ”€β”€ blog/hello-waku/index.html β”‚ β”œβ”€β”€ blog/server-components/index.html β”‚ β”œβ”€β”€ built/index.html β”‚ β”œβ”€β”€ contact/index.html β”‚ β”œβ”€β”€ RSC/ # prerendered payloads for client-side navigation β”‚ β”œβ”€β”€ assets/ # JS and CSS bundles β”‚ └── ... # plus everything copied from public/ └── server/ # the server that renders dynamic routes ``` You can read the rendering model straight out of this listing: - Every static page is now an HTML file, including one page per `staticPaths` entry of the blog post route. - `/now` is nowhere in `dist/public`. A dynamic page has no build-time output; it lives in `dist/server` and executes when requested. - The `RSC` directory holds the prerendered payloads that make client-side navigation between static pages fast. ## Run it ```sh npm run start ``` Open [http://localhost:8080](http://localhost:8080) and check the two pages from the [previous chapter](/guides/static-and-dynamic-rendering): - `/built` shows the moment the build ran, and reloading never changes it. The page executed once, during `npm run build`; you are now being served its artifact. - `/now` shows a new timestamp on every reload. The page executes on each request, and Waku does not implicitly cache the result. This is the whole freshness model, observed: **static at build time, fresh at request time**. To update prerendered content, such as a blog post in `src/lib/posts.ts`, run `npm run build` again: a rebuild produces new artifacts. ## Deploy The starter uses Waku's default adapter, which targets Node.js and automatically switches when the build runs on Vercel, Netlify, or Cloudflare. Other adapters cover AWS Lambda, Deno, and Bun. Fully static sites can skip the server entirely and deploy `dist/public` to any static host. See the deployment guides for specifics: - [Static Deployments](/guides/static-deployments) - [Cloudflare](/guides/cloudflare) - [AWS Lambda](/guides/aws-lambda) - [Docker](/guides/docker) ## Where to go from here You have now seen every idea from [The Mental Model](/guides/concept) working: file-based routing, server and client components, declared rendering modes, and a production build that honors them. - The [guides](/guides) cover specific capabilities: styling, metadata, request context, middleware, and more. - The [waku-examples](https://github.com/wakujs/waku-examples) repository has focused, runnable examples for common patterns. - The [API reference](https://waku.gg/#routing) documents the full routing convention set, server actions, and API routes. --- ### Guides/Learn/Concept --- slug: concept title: The Mental Model description: The five ideas that explain every Waku app. category: Learn order: 10 --- ## Five ideas Everything you will build in this series follows from five ideas. Hold onto these and the rest of Waku is detail. ### 1. Server components first Components are server components by default. They execute on the server, can be `async`, and can fetch data, read files, or query databases directly. Their output, not their code, is sent to the browser. When you need interactivity, you add `'use client'` to a file; that module and everything it imports, directly or transitively, become client code running in the browser. ### 2. Static first Every page and layout is prerendered at build time by default. If a page should instead reflect the current request (current data, cookies, headers), you declare it dynamic with `getConfig`. Static output is a build artifact: it stays exactly the same until the next build replaces it. ### 3. Fresh at request time A dynamic page executes on every request, and Waku does not implicitly cache the result or the data fetched inside it. When your data changes, the next request sees it. There is no revalidation API to call because there is no hidden cache to invalidate. ### 4. All React, small surface Waku adds conventions, not a parallel programming model. Routing is files in `src/pages`, navigation is the `` component, rendering mode is a `getConfig` export, and mutations are React server actions. Data fetching is `await` inside a server component; there is no framework data layer between you and your sources. ### 5. Portable across runtimes The same application deploys through adapters to Node.js, Vercel, Netlify, Cloudflare, AWS Lambda, Deno, and Bun. The React programming model does not change per host; only runtime-specific capabilities (like filesystem access) vary. ## Where code runs | Code | Where it runs | When it runs | | ------------------------- | ---------------------- | ------------------------- | | Static page or layout | Server (build machine) | Once, at build time | | Dynamic page or layout | Server | On every request | | Server action / API route | Server | When invoked | | Client component | Browser | On render and interaction | One nuance: client components also execute once on the server per page render to produce the initial HTML (server-side rendering), then hydrate in the browser. ## What this series proves Each chapter demonstrates one of these ideas hands-on, continuing the project from the [Quick Start](/guides/quick-start): 1. [Pages and Layouts](/guides/pages-and-layouts): routing with files 2. [Server and Client Components](/guides/server-and-client-components): the component model and the `'use client'` boundary 3. [Static and Dynamic Rendering](/guides/static-and-dynamic-rendering): declaring rendering modes and observing freshness 4. [Building for Production](/guides/building-for-production): seeing the model hold in a real build --- ### Guides/Learn/Pages And Layouts --- slug: pages-and-layouts title: Pages and Layouts description: Add routes, nest layouts, and link between pages with the file-based router. category: Learn order: 20 --- ## Files become routes This chapter continues the project from the [Quick Start](/guides/quick-start). Everything inside `src/pages` follows the router's conventions. The starter already contains: - `src/pages/index.tsx` β†’ `/` - `src/pages/about.tsx` β†’ `/about` - `src/pages/_layout.tsx` β†’ the root layout wrapping every page There are two equivalent ways to define a page: a named file (`contact.tsx`) or an index file in a directory (`contact/index.tsx`). Both render at `/contact`. ## Add a page Create `src/pages/contact.tsx`: ```tsx // ./src/pages/contact.tsx import { Link } from 'waku'; export default async function ContactPage() { return (
Contact

Contact

You can reach us at hello@example.com.

Return home
); } ``` With the dev server running, visit [http://localhost:3000/contact](http://localhost:3000/contact). That is the whole route definition; there is no registration step. Two things worth noticing: - The `` element is hoisted into the document head automatically. The same works for `meta` and `link` tags. - `<Link>` performs client-side navigation between pages. Use it instead of `<a>` for internal links. For programmatic navigation, there is also a `useRouter` hook. ## Layouts A `_layout.tsx` file wraps every page in its directory and below. The starter's root layout at `src/pages/_layout.tsx` renders the header, footer, and global styles around all pages. Layouts nest. Let's build a blog section with its own sub-layout. Create `src/pages/blog/_layout.tsx`: ```tsx // ./src/pages/blog/_layout.tsx import type { ReactNode } from 'react'; import { Link } from 'waku'; export default async function BlogLayout({ children, }: { children: ReactNode; }) { return ( <div> <nav className="mb-4"> <Link to="/blog" className="underline"> Blog </Link> </nav> {children} </div> ); } ``` And a blog index page at `src/pages/blog/index.tsx`: ```tsx // ./src/pages/blog/index.tsx export default async function BlogIndexPage() { return ( <div> <title>Blog

Blog

Posts will appear here.

); } ``` Visiting `/blog` now renders the root layout, then the blog layout, then the page, outermost to innermost. ## Dynamic segments Square brackets in a file name declare a route segment whose value comes from the URL. Create `src/pages/blog/[slug].tsx`: ```tsx // ./src/pages/blog/[slug].tsx import type { PageProps } from 'waku/router'; export default async function BlogPostPage({ slug, }: PageProps<'/blog/[slug]'>) { return (
{slug}

{slug}

); } export const getConfig = async () => { return { render: 'dynamic', } as const; }; ``` Visit `/blog/hello-world` and the page receives `slug: 'hello-world'` as a prop, typed via `PageProps`. The `getConfig` export is required here: a segment page must declare how its concrete paths come about: either it renders on demand (`render: 'dynamic'`, as above) or you list the paths to prerender at build time. Rendering modes are the subject of [Static and Dynamic Rendering](/guides/static-and-dynamic-rendering), where we will revisit this page. ## More conventions Other conventions you will meet later, for reference: - `[...parts].tsx`: catch-all segment matching any number of path parts - `(group)/`: route groups that organize files without affecting the URL - `_actions`, `_components/` and `_hooks/`: directories ignored by the router, for co-locating files inside `src/pages` - `_root.tsx`: customizes the outermost document structure - `_api/`: API route handlers - `_slices/`: page fragments with their own rendering mode - `_interceptors/`: request handler interceptors See the [API reference](https://waku.gg/#routing) for the details of each. ## Next Step Pages so far have only rendered markup. [Server and Client Components](/guides/server-and-client-components) brings in data and interactivity. --- ### Guides/Learn/Server And Client Components --- slug: server-and-client-components title: Server and Client Components description: Fetch data in server components and add interactivity at the client boundary. category: Learn order: 30 --- ## Server components by default Components in a Waku app are server components by default: everything is server code except the subtrees you mark with `'use client'`. A `'use client'` module and all of its imports, direct and transitive, are bundled for the browser. Everything outside those subtrees executes only on the server, at build time if the route is static or on each request if it is dynamic, and only its rendered output is sent to the browser; its code never becomes part of the client bundle. That is why they can be `async` and fetch data directly, the way the starter's home page does. Let's use this for real data in the blog from the [previous chapter](/guides/pages-and-layouts). ## Fetch where you render Create a small data module at `src/lib/posts.ts`. In a real application this would query a database or CMS; the shape of the page code stays the same. ```ts // ./src/lib/posts.ts const posts = [ { slug: 'hello-waku', title: 'Hello Waku', body: 'Waku is the minimal React framework.', }, { slug: 'server-components', title: 'Thinking in Server Components', body: 'Fetch where you render.', }, ]; export const getPosts = async () => posts; export const getPost = async (slug: string) => posts.find((post) => post.slug === slug); ``` Update the blog index to list posts: ```tsx // ./src/pages/blog/index.tsx import { Link } from 'waku'; import { getPosts } from '../../lib/posts'; export default async function BlogIndexPage() { const posts = await getPosts(); return (
Blog

Blog

); } ``` And update `src/pages/blog/[slug].tsx` to load the post it is rendering: ```tsx // ./src/pages/blog/[slug].tsx import type { PageProps } from 'waku/router'; import { getPost } from '../../lib/posts'; export default async function BlogPostPage({ slug, }: PageProps<'/blog/[slug]'>) { const post = await getPost(slug); if (!post) { return

Post not found

; } return (
{post.title}

{post.title}

{post.body}

); } export const getConfig = async () => { return { render: 'dynamic', } as const; }; ``` The component fetches its own data with `await`: no loader functions, no data-passing configuration. Composition determines data flow. (For returning a real 404 status, see [Redirects and Not Found](/guides/redirects-and-not-found).) ## Client components for interactivity Server components have no state or event handlers, since they don't run in the browser. When you need interactivity, mark a component with `'use client'`, like the starter's `Counter`. Let's add a like button for blog posts. Create `src/components/like-button.tsx`: ```tsx // ./src/components/like-button.tsx 'use client'; import { useState } from 'react'; export const LikeButton = ({ title }: { title: string }) => { const [liked, setLiked] = useState(false); return ( ); }; ``` Then import it in the post page: ```tsx // ./src/pages/blog/[slug].tsx import { LikeButton } from '../../components/like-button'; ``` and render it below the body text in the returned JSX: ```tsx

{post.body}

``` The post page is still a server component; the like button is a client component receiving a serializable prop across the boundary. Clicking updates state in the browser without involving the server. ## Rules of the boundary - `'use client'` marks the entry to client territory: that file and everything it imports are bundled for the browser. - Server components can render client components. The reverse is not true: a client component cannot import a server component (importing it would pull it into the client bundle), but it can receive server-rendered content via `children` or other props. - Props passed from server to client components must be serializable: no functions (except server actions), class instances, or other non-serializable values. - Client components also execute once on the server per page render to produce the initial HTML, then hydrate in the browser. Code that must never run on the server belongs in effects or event handlers. A useful default: keep components on the server, and push `'use client'` toward the leaves of the tree: the button, not the page. ## Next Step The blog index renders statically while the post page declared `render: 'dynamic'`, but what does that actually mean? [Static and Dynamic Rendering](/guides/static-and-dynamic-rendering) explains both modes, when to choose each, and how to prerender the post pages too. --- ### Guides/Learn/Static And Dynamic Rendering --- slug: static-and-dynamic-rendering title: Static and Dynamic Rendering description: Declare when each route executes and understand Waku's freshness model. category: Learn order: 40 --- ## The model Every page and layout renders in one of two modes, declared by its `getConfig` export: | Mode | When server code runs | How its content updates | | -------------------- | --------------------- | ---------------------------------- | | `'static'` (default) | Once, at build time | The next build replaces it | | `'dynamic'` | On every request | The next request sees current data | Static pages are **prerendered at build time**: the HTML and the component output are produced during `npm run build` and served as-is until a new build replaces them. Dynamic pages are **executed on every request**: they see current data, cookies, and headers. Waku does not implicitly cache dynamic rendering or the data you fetch inside it. What you fetch is what you render, so there is no revalidation step when your data changes; the next request is simply fresh. If you ever want caching, you add it explicitly around the specific expensive work, as a deliberate optimization; see [Explicit Caching](/guides/explicit-caching). ## Static is the default The pages you created without a `getConfig` export, `/contact` and `/blog`, are static. The starter's pages spell it out explicitly, which means the same thing: ```tsx export const getConfig = async () => { return { render: 'static', } as const; }; ``` Static fits any content that is the same for every visitor and only changes when you deploy: marketing pages, documentation, blog posts. Because static pages execute at build time, request-specific information does not exist for them; there is no "current user" during a build. ## Declare a dynamic page Create `src/pages/now.tsx`: ```tsx // ./src/pages/now.tsx export default async function NowPage() { const now = new Date().toISOString(); return (
Now

Now

This page rendered at {now}.

It is executed on every request.

); } export const getConfig = async () => { return { render: 'dynamic', } as const; }; ``` Visit `/now` and reload: the timestamp changes on every request, in development and production alike. For comparison, create a static counterpart at `src/pages/built.tsx`: ```tsx // ./src/pages/built.tsx export default async function BuiltPage() { const builtAt = new Date().toISOString(); return (
Built

Built

This page was prerendered at {builtAt}.

); } ``` Reload `/built` and the timestamp does not change: even in development, a static page executes once and its result is reused. It re-executes when you edit the file, because the dev server re-renders on code changes. In the production build this becomes permanent: the page executes during `npm run build` and its output is frozen until the next build, as the next chapter shows. ## Prerender dynamic segments with staticPaths The blog post page from the previous chapters is `render: 'dynamic'`, executing on every request. But blog posts are stable content, ideal for prerendering. A segment route can be static if you provide the list of paths to prerender. In `src/pages/blog/[slug].tsx`, replace the `getConfig` export (and add `getPosts` to the existing import from `../../lib/posts`): ```tsx export const getConfig = async () => { const posts = await getPosts(); return { render: 'static', staticPaths: posts.map((post) => post.slug), } as const; }; ``` At build time, Waku calls `getConfig`, receives the slugs, and prerenders one page per post. The component code is unchanged; only the declaration of when it runs is different. Note that paths outside the list now return 404 instead of rendering the component. ## Choosing a mode - Same content for every visitor until the next deploy? **Static.** - Depends on the request (user, cookies, headers) or must show current data? **Dynamic.** - Stable shell with one fresh region inside it? Keep the page static and make the layout or a [slice](https://waku.gg/#slices) dynamic; modes are declared per page, per layout, and per slice, so you can mix them in one tree. ## Next Step Declarations only matter if the build honors them. [Building for Production](/guides/building-for-production) runs the production build and shows exactly what got prerendered and what stays fresh. --- ### Guides/Adapter Authoring --- slug: adapter-authoring title: Adapter Authoring description: Build a custom Waku adapter around server entries, request processing, and build processing. category: Low-level APIs order: 30 --- ## When to Write an Adapter Most apps should use a built-in adapter such as `waku/adapters/default`, `waku/adapters/node`, `waku/adapters/cloudflare`, or a deployment-specific adapter. Write a custom adapter only when you need to integrate Waku with a runtime or deployment target that the built-in adapters do not cover. Typical adapter responsibilities are: - translating platform requests into Waku request processing - serving static assets in development or build preview environments - wiring middleware - exposing the platform-specific default export - injecting platform environment bindings into Waku server code - adding deployment files after `waku build` The APIs in this guide currently use `unstable_` names and may change. ## Mental Model A Waku server entry has two jobs: - `fetch` handles runtime requests. - `build` emits static files and build metadata during `waku build`. Application routers such as `fsRouter`, `createPages`, `unstable_defineRouter`, and Minimal API handlers implement `handleRequest` and `handleBuild`. An adapter turns those handlers into the server entry that Waku's Vite plugin and the deployment runtime can execute. `unstable_createServerEntryAdapter` is the usual adapter-authoring helper: ```ts import { unstable_createServerEntryAdapter as createServerEntryAdapter } from 'waku/adapter-builders'; ``` It receives the app's handlers and gives your adapter two wrapped functions: - `processRequest(req)` parses the request, calls `handleRequest`, renders RSC/HTML when needed, and returns a `Response | null`. - `processBuild(utils)` calls `handleBuild` with Waku build helpers such as `renderRsc`, `renderHtml`, `generateFile`, and `saveBuildMetadata`. Your adapter decides when and where to call them. ## Minimal Fetch Adapter This is the smallest useful shape. It handles runtime requests and lets Waku's build process run normally. ```ts // my-waku-adapter.ts import { unstable_createServerEntryAdapter as createServerEntryAdapter } from 'waku/adapter-builders'; export default createServerEntryAdapter(({ processRequest, processBuild }) => { return { fetch: async (req: Request) => { const res = await processRequest(req); return res || new Response('Not Found', { status: 404 }); }, build: processBuild, }; }); ``` Then use it from `src/waku.server.tsx`: ```tsx import { fsRouter } from 'waku'; import adapter from './my-waku-adapter'; export default adapter( fsRouter(import.meta.glob('./**/*.{tsx,ts}', { base: './pages' })), ); ``` This direct shape is useful for learning the contract, but most production adapters need middleware and platform-specific build output. ## Hono-Based Adapter The built-in Waku adapters use Hono internally. That gives them a common middleware shape and lets managed-mode `src/middleware` modules run consistently. ```ts import type { MiddlewareHandler } from 'hono'; import { Hono } from 'hono/tiny'; import { unstable_createServerEntryAdapter as createServerEntryAdapter } from 'waku/adapter-builders'; import { unstable_honoMiddleware as honoMiddleware } from 'waku/internals'; const { rscMiddleware, middlewareRunner } = honoMiddleware; export default createServerEntryAdapter( ( { processRequest, processBuild, notFoundHtml }, options?: { middlewareFns?: ((opts: { app: Hono }) => MiddlewareHandler)[]; middlewareModules?: Record< string, () => Promise<{ default: (opts: { app: Hono }) => MiddlewareHandler }> >; }, ) => { const { middlewareFns = [], middlewareModules = {} } = options || {}; const app = new Hono(); app.notFound((c) => { if (notFoundHtml) { return c.html(notFoundHtml, 404); } return c.text('404 Not Found', 404); }); for (const middlewareFn of middlewareFns) { app.use(middlewareFn({ app })); } app.use(middlewareRunner(middlewareModules, { app })); app.use(rscMiddleware({ processRequest })); return { fetch: app.fetch, build: processBuild, }; }, ); ``` `middlewareRunner(...)` runs middleware modules discovered from `src/middleware`, passing each the `{ app }` Hono instance. `rscMiddleware(...)` delegates the final request handling to Waku. Request context such as `unstable_getRequest` is established by the app's handlers around each render, so the adapter does not install it. `waku/internals` is intentionally internal. It is useful for adapter authors, but it is not a stable application API. ## Static Assets Built-in adapters serve generated static assets from Waku's public output directory when the runtime needs to handle them directly. The shared constant lives in `waku/internals`: ```ts import { unstable_constants as constants } from 'waku/internals'; const { DIST_PUBLIC } = constants; ``` Use `config.distDir` with `DIST_PUBLIC` to locate the generated public files. Many adapters only add static-file middleware while `isBuild` is true, because the build-time preview server needs access to files that were just emitted. ## Adapter Builder Inputs The callback passed to `createServerEntryAdapter` receives: - `handlers`: the original `handleRequest` and `handleBuild` object. - `processRequest`: a Waku request processor that returns `Response | null`. - `processBuild`: a Waku build processor that emits generated files. - `unstable_setAllEnv`: updates Waku's server-side environment map. - `config`: resolved Waku config without the Vite config. - `isBuild`: `true` while Vite is running the build environment. - `notFoundHtml`: prerendered not-found HTML, when available. The returned server entry can include: - `fetch`: the runtime request handler. - `build`: the build-time file emitter. - `buildOptions`: data passed to build enhancers. - `buildEnhancers`: module IDs for post-build wrappers. - `defaultExport`: a platform-specific default export. - other platform-specific fields consumed by your own build enhancer. ## Platform Environment Bindings Some platforms pass environment bindings as extra arguments to `fetch` instead of using `process.env`. Use `unstable_setAllEnv` before delegating to Waku so `getEnv()` and related server code see the platform values. ```ts export default createServerEntryAdapter( ({ processRequest, processBuild, unstable_setAllEnv }) => { return { fetch: async (req: Request, env: Record) => { unstable_setAllEnv(env); const res = await processRequest(req); return res || new Response('Not Found', { status: 404 }); }, build: processBuild, }; }, ); ``` The Cloudflare adapter uses this pattern because Workers pass bindings as the second `fetch` argument. ## Platform Default Exports Some runtimes need the module's default export to have a platform-specific shape. Return `defaultExport` when the deployment target should import something other than the internal Waku server entry object. ```ts export default createServerEntryAdapter( ( { processRequest, processBuild, unstable_setAllEnv }, options?: { handlers?: Record }, ) => { const fetch = async (req: Request, env: Record) => { unstable_setAllEnv(env); const res = await processRequest(req); return res || new Response('Not Found', { status: 404 }); }; return { fetch, build: processBuild, defaultExport: { ...options?.handlers, fetch, }, }; }, ); ``` This is useful for platforms that support extra handlers next to `fetch`, such as queue or scheduled handlers. ## Build Enhancers `processBuild` emits Waku's generated files, but deployment targets often need extra output. For example, Node needs a small `serve-node.js` entry, and serverless platforms may need manifest or config files. Use `buildEnhancers` when an adapter needs to wrap the build step: ```ts export default createServerEntryAdapter( ({ processRequest, processBuild, config }) => { return { fetch: async (req) => { const res = await processRequest(req); return res || new Response('Not Found', { status: 404 }); }, build: processBuild, buildOptions: { distDir: config.distDir, }, buildEnhancers: ['my-waku-adapter/build-enhancer'], }; }, ); ``` Build enhancer module IDs must be resolvable from the project root. Use a package export for reusable adapters, or a project-root-relative path starting with `/` for app-local experiments. A build enhancer receives the build function and returns a wrapped build function: ```ts // build-enhancer.ts import { writeFileSync } from 'node:fs'; import path from 'node:path'; type BuildOptions = { distDir: string; }; export default async function buildEnhancer( build: (utils: unknown, options: BuildOptions) => Promise, ) { return async (utils: unknown, options: BuildOptions) => { await build(utils, options); writeFileSync( path.join(options.distDir, 'platform-entry.js'), "export { default } from './server/index.js';\n", ); }; } ``` Keep build enhancers focused on deployment output. Route rendering and static generation should stay in `handleBuild` or `processBuild`. ## Preview Server Some platform build tools need to execute Waku through a local runtime during `waku build`. Use `unstable_startPreviewServer` for that pattern: ```ts import { unstable_startPreviewServer as startPreviewServer } from 'waku/adapter-builders'; ``` It returns: ```ts type NodeMiddleware = ( req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next: (err?: unknown) => void, ) => void; type PreviewServer = { baseUrl: string; middlewares: { use: (fn: NodeMiddleware) => void; }; close: () => Promise; }; ``` The Cloudflare adapter uses this when it needs the Cloudflare Vite plugin/workerd runtime to participate in static file generation. Only use a preview server when direct `processBuild(...)` is not enough. It is more complex because your adapter must start the server, request the build endpoint, consume the result, and close the server. ## Direct Server Entries `waku/minimal/server` exports `unstable_defineServerEntry` for cases where you already have a complete server entry and do not want to wrap app handlers with an adapter builder. ```ts import { unstable_defineServerEntry as defineServerEntry } from 'waku/minimal/server'; export default defineServerEntry({ fetch: async (req) => { return new Response(`Request: ${new URL(req.url).pathname}`); }, build: async () => {}, }); ``` This bypasses router and Minimal API handler helpers. Use it only when you are intentionally owning the entire server entry contract. ## Practical Boundaries Adapter authors should keep these boundaries clear: - App routing belongs in `fsRouter`, `createPages`, `unstable_defineRouter`, or Minimal API handlers. - Platform request shape, environment bindings, middleware setup, static asset serving, and deployment files belong in the adapter. - Build output that depends on app routes belongs in `handleBuild`/`processBuild`. - Build output that depends on the deployment platform belongs in a build enhancer. Avoid copying large parts of Waku's built-in adapters unless you are intentionally matching that platform. Start from the smallest adapter shape that calls `processRequest` and `processBuild`, then add only the platform behavior your target needs. --- ### Guides/Aws Lambda --- slug: aws-lambda title: Deploy Waku to AWS Lambda description: Deploy a Waku application with the experimental AWS Lambda adapter. category: Deployment order: 20 tags: [Experimental] --- ## Configure the AWS Lambda Adapter Use `waku/adapters/aws-lambda` in `src/waku.server.tsx`: ```ts import { fsRouter } from 'waku'; import adapter from 'waku/adapters/aws-lambda'; export default adapter( fsRouter(import.meta.glob('./**/*.{tsx,ts}', { base: './pages' })), { streaming: false }, ); ``` The AWS Lambda adapter adds a generated handler module during `waku build`: - generated file: `dist/serve-aws-lambda.js` - exported handler: `handler` If the deployment package root is your project root, use `dist/serve-aws-lambda.handler` as the Lambda handler. If the deployment package root is `dist`, use `serve-aws-lambda.handler`. To enable Lambda response streaming, set `streaming: true` in the adapter options: ```ts export default adapter( fsRouter(import.meta.glob('./**/*.{tsx,ts}', { base: './pages' })), { streaming: true }, ); ``` Files that your server code reads directly, such as `fs.readFile('./private/data.json')`, must be included in your deployment package. ## [Serverless Framework](https://www.serverless.com) ### Installation Add this Serverless Framework plugin to your project: ```sh pnpm add -D serverless-scriptable-plugin ``` ### Setup Create a `serverless.yml` with this content and change `service:` to your project name. ```yml service: waku-aws-lambda frameworkVersion: '3' configValidationMode: error provider: name: aws runtime: nodejs20.x architecture: arm64 deploymentMethod: direct region: us-east-1 stage: ${opt:stage, 'dev'} versionFunctions: false plugins: - serverless-scriptable-plugin package: patterns: - '!**/**' - 'private/**' # include all static files and directories from ./private directory - 'dist/**' functions: ssr: handler: dist/serve-aws-lambda.handler events: - httpApi: '*' custom: scriptable: # add custom hooks hooks: before:package:createDeploymentArtifacts: - pnpm exec waku build ``` This configuration will include all files from the `./private` directory in the final deployment. ### Deploy ```sh pnpx serverless deploy ``` Output: ``` Service deployed to stack waku-aws-lambda-dev (95s) endpoint: ANY - https://.execute-api.us-east-1.amazonaws.com functions: ssr: waku-aws-lambda-dev-ssr (325 kB) ``` You can access the frontend through the url provided as the `endpoint:` For more configuration options and how to use a custom domain visit the Serverless framework [documentation](https://www.serverless.com/framework/docs). ## AWS CDK ### Setup Initialize your project with the `cdk` CLI: ```sh mkdir cdk && cd "$_" pnpx cdk init app -l typescript --generate-only cd .. cp cdk/cdk.json . ``` Change the entry in `cdk.json`: ```diff - "app": "npx ts-node --prefer-ts-exts bin/cdk.ts", + "app": "infra/main.js", ``` Remove these lines: ```diff - "watch": { - "include": [ - "**" - ], - "exclude": [ - "README.md", - "cdk*.json", - "**/*.d.ts", - "**/*.js", - "tsconfig.json", - "package*.json", - "yarn.lock", - "node_modules", - "test" - ] - }, ``` Remove the `cdk` directory: ```sh rm -fr cdk ``` Add packages: ```sh pnpm add -D aws-cdk pnpm add aws-cdk-lib constructs ``` Create the infrastructure directory: ```sh mkdir -p infra/lib ``` Bootstrap the AWS CDK: ```sh pnpm cdk bootstrap ``` Create `infra/main.js`: ```js #!/usr/bin/env node //import 'source-map-support/register'; import * as cdk from 'aws-cdk-lib'; import { WakuStack } from './lib/waku-stack.js'; const app = new cdk.App(); new WakuStack(app, 'WakuStack', { /* If you don't specify 'env', this stack will be environment-agnostic. * Account/Region-dependent features and context lookups will not work, * but a single synthesized template can be deployed anywhere. */ /* Uncomment the next line to specialize this stack for the AWS Account * and Region that are implied by the current CLI configuration. */ // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, /* Uncomment the next line if you know exactly what Account and Region you * want to deploy the stack to. */ // env: { account: '123456789012', region: 'us-east-1' }, /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ }); ``` Create `infra/lib/waku-stack.js`: ``` /* Detailed source-code truncated for AI context efficiency. */ ``` This configuration will include all files from the `./private` directory in the final deployment. Deploy to AWS: ```sh pnpm cdk deploy WakuStack ``` For more configuration options and how to use a custom domain, visit the AWS CDK [documentation](https://docs.aws.amazon.com/cdk/v2/guide/home.html). ## sst.dev V3 This example uses Lambda response streaming, so set `streaming: true` in the adapter options. ### Setup 1. Run `pnpm sst@latest init` ### Configuration Use this as an example to run Waku as a Lambda function: sst.config.ts ```ts /// export default $config({ app(input) { return { name: 'waku03demo', removal: input?.stage === 'production' ? 'retain' : 'remove', home: 'aws', }; }, async run() { const WakuDemoApp = new sst.aws.Function('WakuDemoApp', { url: true, streaming: true, //timeout: "15 minutes", handler: 'serve-aws-lambda.handler', bundle: 'dist', // skip SST's esbuild and deploy the prebuilt dist/ as-is copyFiles: [ { from: 'private', }, ], environment: { NODE_ENV: 'production', }, }); return { api: WakuDemoApp.url, }; }, }); ``` ### Deploy ```sh pnpx sst deploy ``` [sst.dev documentation](https://sst.dev/docs) --- ### Guides/Cloudflare --- slug: cloudflare title: Run Waku on Cloudflare description: How to integrate Waku with Cloudflare Workers and interact with Cloudflare bindings and other resources. category: Deployment order: 10 --- ## Quick Start Waku comes "out of the box" with a custom adapter for [Cloudflare Workers](https://developers.cloudflare.com/workers/). Create your project with `npm create waku@latest -- --example https://github.com/wakujs/waku-examples/tree/main/fs-router/cloudflare` to use the starter example for Cloudflare Workers. Then use these commands: - `npm run dev`: start the development server - `npm run build`: build for Cloudflare Workers - `npx wrangler dev`: test your build locally - `npx wrangler deploy`: deploy it to Cloudflare Workers. ## Building Waku For Cloudflare Workers Waku integrates with Cloudflare Workers via [`@cloudflare/vite-plugin`](https://developers.cloudflare.com/workers/vite-plugin/). This plugin runs your code in the actual Cloudflare workerd runtime during both development and build, so Cloudflare bindings (D1, KV, etc.) work without additional shims. > `@cloudflare/vite-plugin` is optional. If you don't need Cloudflare-specific features like D1, KV, or other bindings, you can deploy to Cloudflare Workers without it; just use `waku/adapters/cloudflare` in your `src/waku.server.tsx`. Install `@cloudflare/vite-plugin` and [`wrangler`](https://www.npmjs.com/package/wrangler) as development dependencies: ```sh npm install --save-dev @cloudflare/vite-plugin wrangler ``` Create a `src/waku.server.tsx` file that uses the Cloudflare adapter: ```ts // ./src/waku.server.tsx import { fsRouter } from 'waku'; import adapter from 'waku/adapters/cloudflare'; export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}'))); ``` Add `@cloudflare/vite-plugin` to your `waku.config.ts`: ```ts // ./waku.config.ts import { cloudflare } from '@cloudflare/vite-plugin'; import { defineConfig } from 'waku/config'; export default defineConfig({ vite: { environments: { rsc: { optimizeDeps: { include: ['hono/tiny'], }, build: { rolldownOptions: { platform: 'neutral', }, }, }, ssr: { optimizeDeps: { include: ['waku > rsc-html-stream/server'], }, build: { rolldownOptions: { platform: 'neutral', }, }, }, }, plugins: [ cloudflare({ viteEnvironment: { name: 'rsc', childEnvironments: ['ssr'] }, inspectorPort: false, }), ], }, }); ``` Configure your `wrangler.jsonc` to point to the server entry: ```jsonc // ./wrangler.jsonc { "$schema": "node_modules/wrangler/config-schema.json", "name": "waku-project", "main": "./src/waku.server", "compatibility_flags": ["nodejs_als"], "compatibility_date": "2025-11-17", "assets": { "binding": "ASSETS", "directory": "./dist/public", "html_handling": "drop-trailing-slash", }, "rules": [ { "type": "ESModule", "globs": ["**/*.js", "**/*.mjs"], }, ], "no_bundle": true, } ``` See [Cloudflare's documentation](https://developers.cloudflare.com/workers/wrangler/configuration/) for more information on configuring `wrangler.jsonc`. After setting up, run `waku build` to build and `npx wrangler dev` to test locally, or `npx wrangler deploy` to deploy. ## Notes on Cloudflare's workerd Runtime Cloudflare does not run NodeJS on their servers. Instead, they use their custom JavaScript runtime called [workerd](https://github.com/cloudflare/workerd). By default, workerd does not support built-in NodeJS APIs, but support can be added by editing the `compatibility_flags` in your `wrangler.jsonc` file. Cloudflare does not support all APIs, but the list is growing. For more information, see [Cloudflare's documentation on NodeJS APIs](https://developers.cloudflare.com/workers/runtime-apis/nodejs/) and [compatibility flags](https://developers.cloudflare.com/workers/configuration/compatibility-dates/#setting-compatibility-flags). Waku attempts to stay minimal and compatible with [WinterCG servers](https://wintercg.org/). The Node AsyncLocalStorage API is currently used by Waku, so only the `nodejs_als` compatibility flag is added. If you experience errors in server-side dependencies due to missing NodeJS APIs, try changing this flag to `nodejs_compat` and rebuilding your project. Note that the latest `nodejs_compat` mocks the Node `fs` module. Cloudflare does not allow file system access from server-side functions. See [Cloudflare's security model](https://developers.cloudflare.com/workers/reference/security-model/). ## Setting Up TypeScript You can run `npx wrangler types` to generate a `worker-configuration.d.ts` file based on the settings in your `wrangler.jsonc`. This defines a global `Env` interface with your bindings. In the [Cloudflare example in the Waku examples repository](https://github.com/wakujs/waku-examples/tree/main/fs-router/cloudflare), a package.json script is included to run this command and update the types: `pnpm run cf-typegen`. To ensure that your types are always up-to-date, make sure to run it after any changes to your `wrangler.jsonc` config file. ## Accessing Cloudflare Bindings, Execution Context, and Request/Response Objects Import from `cloudflare:workers` to access Cloudflare Workers bindings such as environment variables, D1 databases and KV namespaces. See [Cloudflare's documentation on Workers bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/) for more information. For general Waku request context usage, see [Request Context](/guides/request-context). > Note: Durable Objects cannot currently be defined in a Waku app. You can create Durable Objects in another Cloudflare Worker and connect to it via [service bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) from your Waku app. ```ts import { env, waitUntil } from 'cloudflare:workers'; // eslint-disable-line import/no-unresolved import { unstable_getRequest as getRequest } from 'waku/router/server'; const getData = async () => { const req = getRequest(); waitUntil( new Promise((resolve) => { console.log('Waiting for 5 seconds'); setTimeout(() => { console.log('OK, done waiting'); resolve(); }, 5000); }), ); const url = new URL(req.url); const userId = url.searchParams.get('userId'); if (!userId) { return null; } const { results } = await env.DB.prepare('SELECT * FROM user WHERE id = ?') .bind(userId) .all(); return results; }; ``` ### Dev Mode The `@cloudflare/vite-plugin` configured in `waku.config.ts` runs your code in the Cloudflare workerd runtime during local development, so Cloudflare bindings like KV, D1, etc. are available in your server components and functions without additional setup. See [Cloudflare's Vite plugin documentation](https://developers.cloudflare.com/workers/vite-plugin/) for more details. ## Additional Handlers Waku supports defining additional [handlers](https://developers.cloudflare.com/workers/runtime-apis/handlers/) for your worker. Pass them to the adapter options in your waku.server.tsx file: ```ts import { fsRouter } from 'waku'; import adapter from 'waku/adapters/cloudflare'; export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')), { handlers: { // Define additional Cloudflare Workers handlers here // https://developers.cloudflare.com/workers/runtime-apis/handlers/ // async queue( // batch: MessageBatch, // _env: Env, // _ctx: ExecutionContext, // ): Promise { // for (const message of batch.messages) { // console.log('Received', message); // } // }, } satisfies ExportedHandler, }); ``` ## Static vs. Dynamic Routing and Fetching Assets When Waku builds for Cloudflare, it outputs the worker function assets into the dist/server folder and outputs static assets into the dist/public folder. A configuration in the `wrangler.jsonc` file tells Cloudflare to route requests to that assets folder first and then fall back to handle the request with the worker. ```json { "assets": { "binding": "ASSETS", "directory": "./dist/public", "html_handling": "drop-trailing-slash" } } ``` You can also access static assets from your server-side worker code in a server component, server function or middleware. For example, if you want to fetch HTML from static assets to render: ```ts import { env } from 'cloudflare:workers'; // eslint-disable-line import/no-unresolved const get404Html = async () => { return env.ASSETS ? await (await env.ASSETS.fetch('https://example.com/404.html')).text() : ''; }; ``` Note that `ASSETS.fetch` requires a fully qualified URL, but the origin is ignored. You can use `https://example.com` or any valid origin. It is just an internal request. It is also possible to always run the worker before serving static assets. See the documentation for [`run_worker_first`](https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first). ### Custom Headers You can set response headers from custom middleware on the Hono response (`c.res`). Since Cloudflare supports response body streaming, server components might not be able to set headers if they were already sent in the response stream. For static assets, add a [`_headers`](https://developers.cloudflare.com/workers/static-assets/headers/#custom-headers) file to the root of your `public` folder to set custom headers for static assets. > An example `_headers` file is included in Waku's starter template for Cloudflare Workers to prevent indexing of RSC files by search engines: `./public/_headers`: ```txt /RSC/* X-Robots-Tag: noindex ``` ### Static Apps Without A Worker It is possible to deploy a static Waku app to Cloudflare Workers. It will deploy the static assets to Cloudflare's edge network without ever invoking any worker functions. To do this, create a `wrangler.jsonc` file with the following content: ```json { "name": "waku-project", "compatibility_date": "2025-11-17", "assets": { "directory": "./dist/public", "html_handling": "drop-trailing-slash" } } ``` and a `src/waku.server.tsx` file that uses the Cloudflare adapter with the `static: true` option: ```ts import { fsRouter } from 'waku'; import adapter from 'waku/adapters/cloudflare'; export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')), { static: true, }); ``` You must also make sure that all of your pages and layouts are defined as static by exporting a getConfig function that specifies `render: 'static'`. For example, in `./src/pages/index.tsx`: ```ts export const getConfig = async () => { return { render: 'static', } as const; }; ``` Then run `npm run build` to build the static assets into the `dist/public` folder, and deploy with `npx wrangler deploy`. --- ### Guides/Csp --- slug: csp title: Configure CSP description: Configure Content Security Policy headers, nonce for inline script, and overall suggestions. category: Runtime and Middleware order: 20 --- [Content Security Policy](https://developer.mozilla.org/docs/Web/HTTP/CSP) (CSP) is important to guard your Waku application against various security threats such as cross-site scripting (XSS), clickjacking, and other code injection attacks. By using CSP, developers can specify which origins are permissible for content sources, scripts, stylesheets, images, fonts, objects, media (audio, video), iframes, and more. ## Inline Script and nonce Waku relies on inline scripts for basic functionality: loading the client entry module, streaming the initial RSC workload, and prefetching assets on navigation. Inline script is also a source of [Cross-site scripting](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/XSS) (XSS). To mitigate this, a common practice is to set [`nonce` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce) on `