Les React Server Components (RSC) sont désormais le modèle mental par défaut du React moderne, mais la plupart des frameworks les enfouissent sous des couches de conventions, de règles de cache et de configuration. Waku prend le parti inverse. Créé par Daishi Kato, l'auteur de Zustand, Jotai et Valtio, Waku est « le framework React minimal » — une fine couche propulsée par Vite qui expose les Server Components, le routage par fichiers et les fonctions serveur sans la moindre lourdeur.
Dans ce tutoriel, vous allez construire un blog petit mais complet avec Waku : des pages d'articles rendues côté serveur, une page d'index générée statiquement, un composant interactif côté client et une fonction serveur qui traite la soumission d'un formulaire. À la fin, vous comprendrez exactement où se situe la frontière serveur/client et comment contrôler le rendu route par route.
Pourquoi Waku, et quand y recourir
Waku ne cherche pas à remplacer Next.js pour toutes les équipes. Il occupe une niche délibérée : le plus petit framework viable qui vous offre tout de même le modèle de programmation complet des React Server Components. Ce positionnement a des conséquences réelles.
- La surface conceptuelle est minuscule. Il y a essentiellement quatre choses à apprendre — les conventions de
src/pages/, le commutateur de rendugetConfig, et les directives'use client'et'use server'. Une fois assimilées, il n'y a aucune couche de cache cachée ni magie de routage à combattre. - Il est natif à Vite. Waku s'appuie sur Vite et React 19 : les plugins, les variables d'environnement et l'expérience de développement semblent donc familiers à quiconque a utilisé une application Vite moderne. Aucune abstraction de bundler sur mesure ne vous fait obstacle.
- Le rendu est une décision explicite, route par route. Au lieu de déduire le comportement statique ou dynamique de la façon dont vous écrivez votre récupération de données, Waku vous fait le déclarer dans
getConfig. Ce compromis — un peu plus de code répétitif pour bien moins d'ambiguïté — est le choix qui définit le framework.
Recourez à Waku lorsque vous voulez apprendre ou enseigner les RSC sans distraction, lorsque vous construisez un site orienté contenu ou une application ciblée, ou lorsque les conventions d'un framework plus lourd dépassent les besoins de votre projet. Recourez à quelque chose de plus grand lorsque vous avez besoin d'un vaste écosystème de plugins, de pipelines d'optimisation d'images ou d'un middleware clé en main.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé (Waku vise les versions récentes de Node et s'appuie sur Vite en interne)
- une aisance avec les fondamentaux de React 19 : composants, props, hooks
- une compréhension de base de ce que sont les Server Components (des composants rendus sur le serveur qui n'envoient jamais leur JavaScript au navigateur)
- un éditeur de code (VS Code recommandé) et un terminal
Vous n'avez besoin d'aucune expérience préalable avec Next.js ou un autre méta-framework. En réalité, Waku est un excellent moyen d'apprendre les RSC, précisément parce qu'il ajoute si peu de choses par-dessus React.
Ce que vous allez construire
Un blog multi-pages nommé Waku Notes, avec :
- une page d'accueil générée statiquement qui liste les articles
- des pages d'articles individuelles rendues dynamiquement via une route basée sur le slug
- une mise en page partagée avec une navigation
- un composant client pour un bouton « j'aime » interactif
- une fonction serveur qui enregistre un commentaire sans route API séparée
Toute la couche de données n'est qu'un tableau en mémoire, afin que vous puissiez vous concentrer sur le framework plutôt que sur une base de données.
Étape 1 : initialisation du projet
Générez un nouveau projet Waku avec le starter officiel :
npm create waku@latestLa CLI demande un nom de projet et un template. Choisissez le template basic et nommez le projet waku-notes. Installez ensuite les dépendances et démarrez le serveur de développement :
cd waku-notes
npm install
npm run devOuvrez http://localhost:3000. Vous devriez voir la page de démarrage de Waku. Examinons la structure créée par le starter :
waku-notes/
├── src/
│ └── pages/
│ ├── _root.tsx # Personnalise <html>, <head>, <body>
│ ├── _layout.tsx # Englobe chaque route (nav, styles, providers)
│ └── index.tsx # La page d'accueil (route : /)
├── public/
├── package.json
└── vite.config.tsL'idée clé : tout ce qui se trouve dans src/pages/ est un Server Component par défaut. Aucune directive n'est nécessaire. La récupération de données, les secrets et l'accès à la base de données vivent tous ici et n'atteignent jamais le navigateur.
Étape 2 : comprendre la frontière serveur/client
Ouvrez src/pages/index.tsx. Comme il ne comporte pas de directive 'use client' en haut, il s'exécute sur le serveur. Cela signifie que vous pouvez écrire du code asynchrone directement dans le composant :
// src/pages/index.tsx — un Server Component
export default async function HomePage() {
const now = new Date().toISOString();
return (
<main>
<h1>Waku Notes</h1>
<p>Rendu sur le serveur à {now}</p>
</main>
);
}Pas de getServerSideProps, pas de loader, pas d'API de données particulière. Le composant est le récupérateur de données. Tout ce que vous attendez avec await ici se produit sur le serveur, et seuls le HTML résultant et une charge utile RSC sérialisée atteignent le client.
Un conseil à intégrer dès le début :
Un Server Component peut importer et rendre des Client Components, mais jamais l'inverse au sein du même module. Un Client Component ne peut recevoir d'un Server Component que des props sérialisables (chaînes, nombres, objets simples et autres enfants rendus côté serveur).
Étape 3 : créer la couche de données
Créez un petit module de données que les deux pages serveur partageront. Créez 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: "Bonjour, Waku",
excerpt: "Pourquoi un framework React minimal compte en 2026.",
body: "Waku garde une surface réduite pour que les Server Components restent lisibles.",
publishedAt: "2026-06-20",
},
{
slug: "rendering-modes",
title: "Rendu statique vs dynamique",
excerpt: "Choisissez le bon mode de rendu par route.",
body: "Les pages sont statiques par défaut ; passez au dynamique quand il faut des données par requête.",
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);
}Dans une vraie application, ces fonctions interrogeraient Postgres, appelleraient une API ou liraient des fichiers MDX. La signature reste identique, car les Server Components se contentent de les attendre avec await.
Étape 4 : construire la page d'accueil statique
Remplacez src/pages/index.tsx par une liste d'articles. Remarquez la fonction exportée getConfig — c'est ainsi que Waku décide comment rendre une 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;
};Deux points à souligner :
Linkest importé depuiswaku. Il effectue une navigation côté client sans rechargement complet de la page, en récupérant la charge utile RSC de la route de destination.getConfigrenvoierender: 'static'. Au moment du build, Waku pré-rend cette page en HTML brut. Elle devient un actif statique hébergeable n'importe où — aucun serveur n'est requis pour cette route.
Les pages sont statiques par défaut, vous pourriez donc techniquement omettre getConfig ici. La déclarer explicitement rend l'intention évidente et constitue une bonne pratique dans une base de code partagée.
Étape 5 : ajouter une mise en page partagée
Ouvrez src/pages/_layout.tsx. Un fichier _layout.tsx englobe sa route et toutes les routes descendantes. La mise en page racine est l'endroit idéal pour la navigation globale et le style partagé :
// 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="/">Accueil</Link>
<Link to="/about">À propos</Link>
</nav>
</header>
<section>{children}</section>
<footer>
<p>Construit avec Waku — le framework React minimal</p>
</footer>
</div>
);
}Les mises en page sont aussi des Server Components. Elles sont rendues une fois sur le serveur et restent montées lors des navigations côté client au sein de leur segment, si bien que l'en-tête ne clignote pas et n'est pas remonté lorsque vous passez d'une page à l'autre.
Étape 6 : créer une route dynamique pour les articles
Maintenant la partie intéressante. Créez src/pages/blog/[slug].tsx. Les crochets font de slug un segment dynamique, et Waku transmet sa valeur en tant que 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>Article introuvable.</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;
};Ici, getConfig renvoie render: 'static' plus un tableau staticPaths. Waku appelle votre fonction de données au moment du build, apprend qu'il existe deux articles et pré-rend /blog/hello-waku et /blog/rendering-modes en HTML statique. C'est l'équivalent de la « génération de site statique avec chemins dynamiques » dans d'autres frameworks, mais exprimée par une petite fonction de configuration colocalisée avec la page.
Si vous préférez que la page soit rendue à neuf à chaque requête — par exemple lorsque le contenu provient d'un CMS qui change en permanence — changez le mode :
export const getConfig = async () => {
return {
render: "dynamic", // SSR : s'exécute sur le serveur à chaque requête
} as const;
};Avec dynamic, vous supprimez entièrement staticPaths, car les chemins sont résolus au moment de la requête. Ce choix route par route est le principal atout ergonomique de Waku : la stratégie de rendu est une décision d'une seule ligne, prise à côté du composant qui en a besoin.
Étape 7 : ajouter un composant client
Jusqu'ici, tout était rendu côté serveur. L'interactivité — l'état, les effets, les gestionnaires d'événements — nécessite un Client Component. Créez src/components/LikeButton.tsx et marquez-le avec '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>
);
}La directive 'use client' marque la frontière : ce module et ses dépendances sont bundlés et envoyés au navigateur. Tout ce qui se trouve au-dessus de lui dans l'arbre reste exclusivement côté serveur.
Rendez-le maintenant depuis la page d'article (un Server Component). Le Server Component transmet un simple nombre en tant que prop à travers la frontière :
// src/pages/blog/[slug].tsx (extrait)
import { LikeButton } from "../../components/LikeButton";
// ...à l'intérieur de PostPage, après <p>{post.body}</p> :
<LikeButton initial={0} />Rechargez une page d'article et cliquez sur le bouton — le compteur se met à jour instantanément dans le navigateur, tandis que l'article environnant reste rendu côté serveur. Vous disposez d'un îlot d'hydratation précis et minimal.
Étape 8 : traiter un formulaire avec une fonction serveur
Waku prend en charge les fonctions serveur (la directive 'use server'), qui permettent à un Client Component d'appeler du code côté serveur directement — sans route API manuelle ni code fetch répétitif. Créez 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: "Le commentaire ne peut pas être vide" };
}
comments.push({ slug, text });
return { ok: true, count: comments.filter((c) => c.slug === slug).length };
}La directive 'use server' en haut du fichier signifie que chaque fonction exportée s'exécute sur le serveur, même invoquée depuis le navigateur. Waku génère la tuyauterie RPC pour vous. Appelez-la maintenant depuis un 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(`Enregistré. Total des commentaires : ${result.count}`);
setText("");
} else {
setStatus(result.error ?? "Une erreur est survenue");
}
}
return (
<div>
<textarea value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={submit}>Publier le commentaire</button>
<p>{status}</p>
</div>
);
}L'import paraît tout à fait ordinaire, mais addComment ne s'exécute jamais dans le navigateur. L'argument est sérialisé, envoyé au serveur, exécuté là-bas, et la valeur de retour revient — le tout vérifié de bout en bout au niveau des types, car ce n'est que du TypeScript ordinaire. Placez <CommentBox slug={post.slug} /> dans la page d'article, sous le bouton « j'aime ».
Étape 9 : routage programmatique (optionnel, avancé)
Le routage par fichiers couvre la plupart des applications, mais Waku expose aussi une API de plus bas niveau, createPages, lorsque les routes sont générées à partir de données ou que vous voulez un contrôle total. Créez 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 prend les mêmes render, path et component que vous connaissez déjà du routage par fichiers — elle vous permet simplement de construire la table de routes par programmation. Des méthodes complémentaires existent : createLayout pour les enveloppes, createRoot pour personnaliser la coquille du document, et createApi pour des gestionnaires de requêtes bruts. N'y recourez que lorsque les conventions de fichiers deviennent limitantes ; pour le blog, l'approche par fichiers est plus propre.
Étape 10 : build et déploiement
Produisez un build de production :
npm run buildWaku pré-rend chaque route render: 'static' en HTML, bundle les îlots client et prépare un point d'entrée serveur pour les routes render: 'dynamic'. Prévisualisez localement :
npm run startWaku est agnostique vis-à-vis du déploiement et fournit des adaptateurs pour les principales plateformes. Pour cibler Vercel, par exemple, définissez l'environnement de déploiement avant le build :
npm run build -- --with-vercelLes cibles prises en charge incluent Node.js, Vercel, Netlify, Cloudflare Workers, Deno Deploy, Bun et AWS Lambda. Un blog entièrement statique (chaque route en render: 'static') peut même être déposé sur n'importe quel CDN ou stockage objet, car la sortie du build n'est que du HTML et des actifs.
Note de déploiement MENA : si votre audience se trouve en Tunisie, dans le Golfe ou plus largement dans la région MENA, privilégiez une cible edge (Cloudflare Workers) ou une région proche de vos utilisateurs. Les routes statiques servies depuis un CDN éliminent entièrement la latence aller-retour, ce qui compte sur des connexions mobiles instables.
Tester votre implémentation
Vérifiez que le build se comporte comme prévu :
- Lancez
npm run buildet confirmez que la sortie liste du HTML pré-rendu pour/,/blog/hello-wakuet/blog/rendering-modes. - Lancez
npm run start, puis désactivez JavaScript dans les outils de développement du navigateur et rechargez une page d'article statique — le contenu doit tout de même s'afficher, prouvant qu'il a été rendu côté serveur. - Réactivez JavaScript et cliquez sur le bouton « j'aime » — le compteur doit s'incrémenter sans requête réseau, prouvant que l'îlot client a été hydraté.
- Soumettez un commentaire et confirmez que le compte renvoyé augmente, prouvant que la fonction serveur s'est exécutée à distance.
Dépannage
« Les Hooks ne peuvent être appelés que dans un Client Component. » Vous avez utilisé useState, useEffect ou un gestionnaire d'événements dans un fichier sans 'use server' et sans 'use client'. Ajoutez 'use client' en haut du fichier de ce composant.
Un Client Component rend un Server Component et plante. Les Server Components ne peuvent pas être importés dans des Client Components. À la place, transmettez le Server Component en tant que children depuis un Server Component parent.
Une route staticPaths renvoie une erreur 404. Le slug demandé ne figure pas dans le tableau renvoyé par votre getConfig au moment du build. Pour du contenu qui change souvent, passez la route en render: 'dynamic'.
Une fonction serveur lève « ne peut pas être appelée sur le serveur pendant le rendu ». Les fonctions serveur sont destinées à être invoquées depuis les gestionnaires d'événements d'un Client Component, et non pendant la passe de rendu d'un Server Component. Pour des données nécessaires au rendu, appelez directement votre module de données.
Étapes suivantes
- Remplacez le tableau en mémoire par une vraie base de données. Associez-le à notre guide Drizzle ORM avec Next.js — les patterns de couche de données se transfèrent directement aux Server Components de Waku.
- Ajoutez des métadonnées et des balises SEO via
_root.tsxpour des titres et des données Open Graph propres à chaque route. - Explorez les Slices de Waku pour un rendu partiel fin de fragments d'interface partagés.
- Comparez les approches des méta-frameworks avec notre tutoriel TanStack Start vs Next.js pour voir où un framework RSC minimal s'insère dans votre stack.
Conclusion
Waku prouve que les React Server Components n'exigent pas un framework lourd. Avec un seul répertoire de conventions, une fonction getConfig exportée pour le rendu par route, les frontières 'use client' et 'use server', et une poignée d'adaptateurs de déploiement, vous disposez de tout le nécessaire pour livrer une application React rapide et orientée serveur. Vous avez construit un blog avec des routes statiques et dynamiques, un îlot client hydraté et une fonction serveur — et à chaque étape, la frontière serveur/client est restée explicite et lisible. Cette clarté est toute la raison d'être de Waku : garder le framework minimal pour que votre React reste la vedette.