18 juin 2025
TanStack Start: la relève des frameworks React ?

13 minutes de lecture

Il y a quelques semaines de ça, une faille de sécurité a été découverte dans NextJS au niveau de la gestion de l'authentification au sein du fichier middleware. Cette faille a été corrigée assez rapidement, mais Vercel n'a pas réellement communiqué dessus à vrai dire. Par la suite, les réseaux se sont enflammés et pour la plupart des développeurs, c'était la goutte de trop pour un framework qui souffrait déjà de bien des inconvénients notamment en terme de DX. J'ai pu voir passer de nombreux retours très positifs de la part de développeurs ayant complètement migré de NextJS vers TanStack Start (que nous nommerons Start
pour la suite de cet article). Alors au final, est-ce que ce nouveau framework a des chances de concurrencer Remix ou NextJS, déjà bien en place dans l'écosystème React ?
Présentation technique
Start, créé par Tanner Linsley que l'on ne présente plus, possède un routing basé sur une librairie déjà existante, TanStack Router. Utilisable de base pour les SPA, Start l'intègre notamment pour effectuer du SSR, à la manière de ce que fait Remix avec React Router.
Côté bundling, Start se base sur Vite, nous permettant ainsi de bénéficier de tous les plugins Vite disponibles. Pour rappel, Next se base sur SWC ou TurboPack selon les versions.
Explorons plus en détails Start via une petite application toute simple avec une authentification basique, une liste de posts et les détails d'un post.
Exploration
Mise en place
Start propose un exemple de base à cloner faisant office de starter kit. Nous allons ici mettre en place un projet en partant de zéro, utilisant Tailwind CSS.
mkdir starter-example
cd starter-example
yarn init -y
Installons maintenant les dépendances :
yarn add @tanstack/react-start @tanstack/react-router react react-dom zod
yarn add -D vite @vitejs/plugin-react typescript @types/react @types/react-dom tailwindcss @tailwindcss/vite
Mettons en place nos différents fichiers de configuration :
{
"compilerOptions": {
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"module": "ESNext",
"target": "ES2022",
"skipLibCheck": true,
"strictNullChecks": true,
},
}
import { defineConfig } from 'vite'
import tailwindcss from "@tailwindcss/vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
export default defineConfig({
plugins: [
tanstackStart(),
tailwindcss()
],
},
})
Mettons à jour les scripts de notre package.json
:
{
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "node .output/server/index.mjs"
}
}
Créons maintenant notre points d'entrée requis par Start. Celui-ci se situera dans le dossier src
à la racine du projet.
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function createRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
/*
* il est ici possible de configurer les options par défaut du routeur,
* comme par exemple le composant par défaut pour les routes en not found,
*/
})
return router
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createRouter>
}
}
Le fichier routeTree.gen
n'existe pas et c'est normal, il est généré automatiquement par le
bundler. C'est notamment lui qui est en charge de gérer le typage de nos routes.
Créons notre feuille de style CSS pour Tailwind :
@import "tailwindcss";
Créons maintenant notre document HTML principal. C'est l'équivalent du fichier layout.tsx
à la racine du dossier app
d'une application Next. Ce fichier ce situe dans un sous-dossier routes
. C'est dans ce sous-dossier que ce situeront toutes les routes de notre application.
import type { ReactNode } from "react";
import {
Outlet,
createRootRoute,
HeadContent,
Scripts,
} from "@tanstack/react-router";
// import de la feuille de style au format URL
import appCss from "../styles/styles.css?url";
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{
title: "Start Example",
},
],
links: [
{
rel: "stylesheet",
href: appCss,
},
],
}),
component: RootComponent,
});
function RootComponent() {
return (
<RootDocument>
<Outlet />
</RootDocument>
);
}
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
return (
<html>
<head>
<HeadContent />
</head>
<body className="bg-white dark:bg-slate-900">
<div className="min-h-screen flex flex-col">{children}</div>
<Scripts />
</body>
</html>
);
}
Le composant Outlet
est un composant très utile pour construire des layouts imbriqués. Celui-ci rend le contenu de la route active et peut être utilisé à d'autres endroits de l'application. Nous verrons un cas d'utilisation plus tard.
Nous pouvons maintenant exécuter yarn dev
pour démarrer notre bundler.
Notre première route
Créons un fichier src/routes/index.tsx
pour notre première route. Si vous avez votre bundler d'ouvert, vous constaterez une fonctionnalité très intéressante venant de TanStack Router : un template de base pour le fichier avec tout le contenu nécessaire est généré. Celui-ci change en fonction du chemin du fichier. Par exemple, renommer le fichier changera le chemin de la route.
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: Home,
});
function Home() {
return (
<form
className="flex flex-1 flex-col justify-center items-center gap-4"
>
<div className="flex flex-col gap-2">
<label htmlFor="username" className="dark:text-white">
Username
</label>
<input
name="username"
id="username"
placeholder="Username"
className="dark:text-white border dark:border-white"
/>
<label htmlFor="password" className="dark:text-white">
Password
</label>
<input
name="password"
id="password"
placeholder="Password"
type="password"
className="dark:text-white border dark:border-white"
/>
</div>
<button
type="submit"
className="bg-blue-500 px-4 py-3 rounded-md text-white cursor-pointer"
>
Login
</button>
</form>
);
}
En se rendant sur http://localhost:3000
, notre page formulaire s'affiche correctement !
Mettons maintenant en place notre seconde route, qui sera notre liste de posts. Cette route devra être plus tard protégée par une authentification.
import { createFileRoute } from "@tanstack/react-router";
import PostCard from "../components/PostCard";
import { loadPosts } from "../functions/posts";
export const Route = createFileRoute('/posts')({
component: RouteComponent,
loader: () => {
return loadPosts()
},
})
function RouteComponent() {
const loadedPosts = Route.useLoaderData();
return (
<div className="flex flex-1 justify-center items-center">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{loadedPosts.map((post) => (
<PostCard
key={post.id}
title={post.title}
content={post.content}
id={post.id}
/>
))}
</div>
</div>
);
}
import { Link } from "@tanstack/react-router";
type Props = {
title: string;
content: string;
id: number;
};
const PostCard = ({ title, content, id }: Props) => {
return (
<Link
to="/posts/$id"
// Prefetch de la page au hover
preload="intent"
params={{ id: id.toString() }}
className="bg-white shadow-md rounded-lg p-6 mb-6 dark:shadow-white hover:scale-[101%] transition-transform duration-300 cursor-pointer"
>
<h2 className="text-2xl font-bold mb-4">{title}</h2>
<p className="text-gray-700 line-clamp-3 text-ellipsis">{content}</p>
</Link>
);
};
export default PostCard;
Ici on retrouve une API assez similaire à celle de Remix pour charger de la donnée côté serveur :
loader
est exécuté au chargement de la page et renvoie notre donnée- on récupère cette donnée via le hook
useLoaderData
accessible depuis notre objetRoute
.
Ici, notre liste de posts est chargée depuis une fonction serveur, l'opportunité de faire la connaissance avec les server functions de Start.
Server functions
Les server functions sont, comme celles de React et Next, des fonctions appelées depuis n'importe où (sauf les routes API) et exécutées côté serveur. Les server functions de Start ont toutefois des fonctionnalités en plus par rapport à ce que l'on retrouve par exemple sur Next :
- possibilité de définir la méthode HTTP à utiliser (POST ou GET)
- ajout de validation (via zod par exemple)
- accès à l'ensemble de la requête HTTP
- possibilité de définir le type de réponse (JSON, stream)
Créons notre fonction loadPosts
:
import { createServerFn } from "@tanstack/react-start";
import { posts } from "../mocks/posts";
export const loadPosts = createServerFn()
.handler((async) => {
return posts;
});
export const posts = Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
title: "Lorem ipsum dolor sit amet.",
content: '....lorem ipsum',
}));
Profitons-en pour créer notre fonction de login pour notre formulaire ! Chose très intéressante, Start propose une solution clé en main pour gérer les sessions, que ce soit via le session storage ou les cookies.
import { createServerFn, json } from "@tanstack/react-start";
import { setResponseStatus } from "@tanstack/react-start/server";
import { z } from "zod";
import { redirect } from "@tanstack/react-router";
import { setTimeout } from "node:timers/promises";
import { useAppSession } from "../utils/session";
export const loginAction = createServerFn({
method: "POST",
})
.validator((data: unknown) => {
const schema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
const result = schema.safeParse(data);
if (!result.success) {
throw json(
{ error: result.error.issues, message: "Validation failed" },
{ status: 422 },
);
}
return result.data;
})
.handler(async ({ data }) => {
// Fake delay
await setTimeout(1000);
if (data.username !== "admin" && data.password !== "admin") {
setResponseStatus(401);
return {
success: false,
error: "Unauthorized",
};
}
const session = await useAppSession();
await session.update({
name: data.username,
});
// On redirige vers la page de posts après la connexion réussie
throw redirect({
to: "/posts",
});
});
export const logoutAction = createServerFn({ method: "POST" }).handler(
async () => {
const session = await useAppSession();
await session.clear();
throw redirect({
to: "/",
});
},
);
import { json } from "@tanstack/react-start";
import { useSession } from "@tanstack/react-start/server";
type UserSession = {
name: string;
};
export const useAppSession = () => {
return useSession<UserSession>({
password: "MyPasswordAsALongStringOfCharactersOfAtLeast32Characters",
// On utilise les cookies, on peut configurer les options de cookie ici.
// Par défaut, le session storage est utilisé.
cookie: {},
});
};
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { loginAction } from "../functions/auth";
export const Route = createFileRoute("/")({
component: Home,
});
function Home() {
const login = useServerFn(loginAction);
return (
<form
className="flex flex-1 flex-col justify-center items-center gap-4"
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const username = formData.get("username") as string;
const password = formData.get("password") as string;
login({ data: { username, password } });
}}
>
<div className="flex flex-col gap-2">
<label htmlFor="username" className="dark:text-white">
Username
</label>
<input
name="username"
id="username"
placeholder="Username"
className="dark:text-white border dark:border-white"
/>
<label htmlFor="password" className="dark:text-white">
Password
</label>
<input
name="password"
id="password"
placeholder="Password"
type="password"
className="dark:text-white border dark:border-white"
/>
</div>
<button
type="submit"
className="bg-blue-500 px-4 py-3 rounded-md text-white cursor-pointer"
>
Login
</button>
</form>
);
}
Et voilà, après la connexion, vous êtes maintenant redirigé vers la liste des posts !
Le concept de layout
Un layout a pour but de créer une structure statique dans laquelle va s'intégrer le contenu de notre route. Avec Next, nous créons un layout via une route layout.tsx
au même niveau que la route. Start (via l'intermédiaire du Router) a une approche très similaire à React Router pour gérer les layouts. Tant qu'une partie de notre URL est matchée, alors sa route est rendue.
Par exemple :
/posts
-><RootDocument><Posts>
/posts/:id
-><RootDocument><Posts><Post>
Ce comportement est configurable en fonction du nom du fichier. Par conséquent, par défaut, une route comme /posts
que l'on a créé peut agir comme un layout pour /posts/:id
. Le contenu de /posts/:id
sera affiché via le composant Outlet
.
Il est aussi possible de faire en sorte de créer un layout qui n'est pas lié à une route. C'est ce que l'on appelle un pathless layout
. Celui-ci aura un nom de fichier préfixé d'un _
.
Créons un layout utilisé au sein de nos routes authentifiées :
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { logoutAction } from "../functions/auth";
export const Route = createFileRoute("/_app")({
component: RouteComponent,
});
function RouteComponent() {
const logout = useServerFn(logoutAction);
return (
<div className="flex flex-col flex-1">
<nav className="flex items-center justify-end py-3 px-4">
<ul>
<li
className="dark:text-white hover:underline cursor-pointer"
onClick={() => {
logout();
}}
>
Logout
</li>
</ul>
</nav>
<div className="px-4">
<Outlet />
</div>
</div>
);
}
Nous devons maintenant déplacer notre route posts.tsx
dans un sous dossier _app
, correspondant au nom de notre layout. Évidemment, nous pouvons donner le nom que l'on veut à notre layout. Il faut simplement garder une correspondance entre le nom du fichier du layout et le dossier créé.
Notre liste de posts est maintenant bien intégré au sein de notre layout. Il est évidemment possible de nester les layouts à l'infini, c'est le composant Outlet
qui se charge de rendre les routes enfant à l'emplacement désiré.
Nous pouvons maintenant, grâce au layout, prévenir l'accès à nos pages authentifiées. En effet, un pathless layout dispose des mêmes possibilités d'usage d'une route. On peut donc utiliser la propriété beforeLoad
afin de vérifier la connexion de l'utilisateur.
import type { ReactNode } from "react";
import {
Outlet,
createRootRoute,
HeadContent,
Scripts,
} from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import appCss from "../styles/styles.css?url";
import { useAppSession } from "../utils/session";
const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
const session = await useAppSession();
if (!session.data.name) {
return null;
}
return { name: session.data.name };
});
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{
title: "Start Example",
},
],
links: [
{
rel: "stylesheet",
href: appCss,
},
],
}),
component: RootComponent,
beforeLoad: async () => {
const user = await fetchUser();
return { user };
},
});
function RootComponent() {
return (
<RootDocument>
<Outlet />
</RootDocument>
);
}
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
return (
<html>
<head>
<HeadContent />
</head>
<body className="bg-white dark:bg-slate-900">
<div className="min-h-screen flex flex-col">{children}</div>
<Scripts />
</body>
</html>
);
}
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { logoutAction } from "../functions/auth";
export const Route = createFileRoute("/_app")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
const isLoggedIn = !!context.user;
if (!isLoggedIn) {
throw redirect({
to: "/",
});
}
},
});
function RouteComponent() {
const logout = useServerFn(logoutAction);
return (
<div className="flex flex-col flex-1">
<nav className="flex items-center justify-end py-3 px-4">
<ul>
<li
className="dark:text-white hover:underline cursor-pointer"
onClick={() => {
logout();
}}
>
Logout
</li>
</ul>
</nav>
<div className="px-4">
<Outlet />
</div>
</div>
);
}
Et voilà, maintenant toutes les routes enfant de notre layout _app
sont protégées !
Routes API
Comme tout framework full-stack qui se respecte, Start dispose d'un concept de routes API. La notation de fichier pour le routing est la même que pour nos pages classiques.
Créons une route API nous permettant de récupérer notre liste de posts :
import { json } from "@tanstack/react-start";
import { createServerFileRoute } from "@tanstack/react-start/server"
import { posts } from "../../../mocks/posts";
export const APIRoute = createServerFileRoute("/api/posts").methods({
GET: async ({ request }) => {
return json(posts);
},
});
Que ce soit pour les server functions ou les routes API, il est possible d'utiliser tout un ensemble de fonctions utilitaires fournies par Stack afin de gérer à le contenu d'une requête et d'une réponse. C'est le cas de la fonction json
. Par exemple si l'on souhaite renvoyer un statut HTTP particulier pour notre requête :
import { json } from "@tanstack/react-start";
import { setResponseStatus, createServerFileRoute } from "@tanstack/react-start/server";
import { posts } from "../../../mocks/posts";
export const APIRoute = createServerFileRoute("/api/posts").methods({
GET: async ({ request }) => {
setResponseStatus(200);
return json(posts);
},
});
Middlewares
Les middlewares sont des fonctions que l'on branche aux server functions afin d'intercepter l'exécution de celles-ci. Elles permettent d'ajouter du contexte à nos requêtes, que ce soit lors de l'exécution côté client ou côté serveur.
Créons un middleware afin de rediriger l'utilisateur vers la page d'accueil s'il tente d'accéder à notre liste de posts en tant qu'utilisateur anonyme.
import { createMiddleware } from "@tanstack/react-start";
import { redirect } from "@tanstack/react-router";
import { useAppSession } from "../utils/session";
export const authMiddleware = createMiddleware().server(async ({ next }) => {
const user = await useAppSession();
if (!user.data.name) {
throw redirect({
to: "/",
});
}
return next();
});
import { createServerFn } from "@tanstack/react-start";
import { authMiddleware } from "../middlewares/auth";
import { posts } from "../mocks/posts";
export const loadPosts = createServerFn()
.middleware([authMiddleware])
.handler((async) => {
return posts;
});
Et voilà, maintenant le handler
de notre server function ne s'exécutera uniquement si l'utilisateur est authentifié.
Un middleware est aussi utilisable sur une route API :
import { json } from "@tanstack/react-start";
import { setResponseStatus, createServerFileRoute } from "@tanstack/react-start/server";
import { posts } from "../../../mocks/posts";
import { authMiddleware } from "../../../middlewares/auth";
export const APIRoute = createServerFileRoute("/api/posts").methods((api) => ({
GET: api.middleware([authMiddleware]).handler(async ({ request }) => {
setResponseStatus(200);
return json(posts);
}),
}));
DevTools
Un gros quick win par rapport à Next : la possibilité de débugger les routes (les données du loader par exemple). Cela est disponible via les DevTools de TanStack Router, à intégrer directement au sein du composant RootDocument
.

Déploiement
Le déploiement d'une application Start est très simple. Différents presets sont fournis, à passer soit au sein de la configuration, soit via un flag au moment du build. Pour un simple serveur node par exemple :
yarn build
node .output/server/index.mjs
Start supporte les services d'hébergements les plus importants du marché comme Vercel et Netlify.
Conclusion
Globalement j'ai eu une bonne expérience avec Start. Une DX très agréable, une documentation très complète (comme toutes les librairies TanStack) et toutes les fonctionnalités que l'on attends d'un framework full-stack. Le fonctionnement des server functions est, selon moi, bien mieux que sur NextJS (méthode GET/POST
, réponse customisable). L'ajout de devtools liés au routing est aussi un énorme plus. C'est en tout cas une solution sur laquelle nous allons garder un oeil attentif, car c'est une alternative très intéressante par rapport à NextJS. Une idée de projet qui nécessiterait TanStack Start ? Utilisez notre estimateur de projet !