AccueilClientsExpertisesOpen SourceBlogContactEstimer

18 juin 2025

TanStack Start: the Future of React Frameworks?

12 minutes reading

TanStack Start: the Future of React Frameworks?
🇫🇷 This post is also available in french

A few weeks ago, a security vulnerability was discovered in NextJS’s handling of authentication in the middleware file. The issue was fixed fairly quickly, but to be honest, Vercel didn’t really communicate much about it. Social media went wild afterwards, and for many developers, it was the last straw for a framework already plagued by many drawbacks, especially regarding DX. I saw a lot of positive feedback from developers who had completely swapped out NextJS in favor of TanStack Start (which I’ll refer to as Start for the rest of this article). So, does this new framework have what it takes to challenge Remix or NextJS, which have already cemented their place in the React ecosystem?

Technical Overview

Start, created by the well-known Tanner Linsley, uses routing based on an existing library, TanStack Router. While you can use the router for SPAs, Start integrates it specifically for SSR, just like Remix does with React Router.

For bundling, Start relies on Vite, allowing us to take advantage of the vast ecosystem of Vite plugins. For reference, Next is based on SWC or TurboPack, depending on the version.

Let’s dive deeper into Start by building a simple application featuring basic authentication, a list of posts, and individual post details.

Hands-On Exploration

Setup

Start offers a basic example starter kit to clone. Here, though, we'll build a project from scratch using Tailwind CSS.

mkdir starter-example
cd starter-example
yarn init -y

Now, let’s install the dependencies:

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

Let’s set up the different configuration files:

tsconfig.json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "moduleResolution": "Bundler",
    "module": "ESNext",
    "target": "ES2022",
    "skipLibCheck": true,
    "strictNullChecks": true,
  },
}
vite.config.ts
import { defineConfig } from 'vite'
import tailwindcss from "@tailwindcss/vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";

export default defineConfig({
    plugins: [
      tanstackStart(),
      tailwindcss()
    ],
  },
})

Let’s update the scripts in our package.json:

package.json
{
  "scripts": {
    "dev": "vite dev",
    "build": "vite build",
    "start": "node .output/server/index.mjs"
  }
}

Now, let’s create our Start entry point in the src folder at the project root.

src/router.tsx
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export function createRouter() {
    const router = createTanStackRouter({
    routeTree,
    scrollRestoration: true,
    /*
        * Here you can set up default router options,
        * such as the default component for not found routes, etc.
        */
    })

    return router

}

declare module '@tanstack/react-router' {
  interface Register {
    router: ReturnType<typeof createRouter>
  }
}

The routeTree.gen file doesn't exist yet—this is normal. It's generated automatically by the bundler. This file is responsible for strongly typing our routes.

Create our Tailwind CSS stylesheet:

app/styles/styles.css
@import "tailwindcss";

Now, let’s create our main HTML document. This is the equivalent of the layout.tsx file at the root of the app folder in a Next application. This file goes in a routes subfolder, where all application routes are placed.

src/routes/__root.tsx
import type { ReactNode } from "react";
import {
  Outlet,
  createRootRoute,
  HeadContent,
  Scripts,
} from "@tanstack/react-router";
// import stylesheet as 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>
  );
}

The Outlet component is incredibly useful for building nested layouts. It renders the content of the active route and can also be used in other places in the app—we’ll see specific use cases later.

You can now run yarn dev to start your bundler.

Your First Route

Let’s create a src/routes/index.tsx file for our first route. If you have your bundler running, you'll notice a very cool feature from TanStack Router: a base template for the file, already filled with the necessary content, is generated automatically. This even adapts based on the file path—for example, renaming the file updates the route path.

src/routes/index.tsx
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>
  );
}

Visiting http://localhost:3000 now displays our form page correctly!

Let’s set up our second route, a post list, which we’ll later protect with authentication.

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>
  );
}

This API is pretty similar to Remix for server-side data fetching:

  • loader runs when the page loads and returns your data
  • you access this data with the useLoaderData hook from the Route object.

Here, our post list is loaded from a server function, providing an opportunity to get to know Start’s server functions.

Server Functions

Server functions—much like those in React and Next—are functions you can call from anywhere (except API routes) and are executed on the server. Start’s server functions, however, have some extra features compared to what’s available in Next:

  • Ability to set the HTTP method (POST or GET)
  • Validation (with zod, for example)
  • Access to the full HTTP request
  • Control over response type (JSON, stream…)

Let’s create our loadPosts function:

import { createServerFn } from "@tanstack/react-start";
import { posts } from "../mocks/posts";

export const loadPosts = createServerFn()
  .handler((async) => {
    return posts;
  });

Let’s also create our login function for the form! One very nice feature: Start includes an out-of-the-box session management solution, whether via session storage or 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,
    });

    // Redirect to posts page after successful login
    throw redirect({
      to: "/posts",
    });
  });

export const logoutAction = createServerFn({ method: "POST" }).handler(
  async () => {
    const session = await useAppSession();

    await session.clear();

    throw redirect({
      to: "/",
    });
  },
);

And that’s it—after logging in, you’re redirected to the post list!

The Layout Concept

A layout provides a static structure that wraps around the content of our route. In Next, you’d create a layout with a layout.tsx route at the same level as the route itself. Start (via the router) takes an approach very similar to React Router for handling layouts. As long as a part of your URL matches, that route component is rendered.

For example:

  • /posts -> <RootDocument><Posts>
  • /posts/:id -> <RootDocument><Posts><Post>

This is configurable based on file naming conventions. By default, a route like /posts acts as a layout for /posts/:id. The content for /posts/:id will be rendered via the Outlet component.

You can also create layouts that aren't tied to a specific route—these are called pathless layouts. Their files are prefixed with an underscore (_).

Let’s create a layout to use inside our authenticated routes:

src/routes/_app.tsx
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>
  );
}

Now, move your posts.tsx route into a _app subfolder corresponding with our layout name. Of course, you can name your layouts anything you want—as long as the layout file name and folder correspond.

Our post list is now integrated within our layout. You can nest layouts infinitely; the Outlet component renders child routes exactly where you want them.

Thanks to layouts, we can protect access to authenticated pages. Pathless layouts have the same capabilities as routes, so you can use the beforeLoad property to check if the user is logged in.

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>
  );
}

And that's it: all child routes under the _app layout are now protected!

API Routes

As with any good full-stack framework, Start offers the concept of API routes. File naming for API routing is the same as for regular pages.

Let’s create an API route to fetch our list of posts:

src/routes/api/posts/index.ts
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);
  },
});

For both server functions and API routes, you can use a wide range of utility functions provided by TanStack to manage a request and its response. For example, to return a specific HTTP status code:

src/routes/api/posts/index.ts
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

Middlewares are functions you can hook into server functions to intercept their execution. They let you add context to requests, whether running on the client or server.

Let’s create a middleware that redirects users to the home page if they try to access the post list while not authenticated.

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();
});

Now, the handler for our server function will only execute if the user is authenticated.

You can also use a middleware on an API route:

src/routes/api/posts/index.ts
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

A big win compared to Next: you have the ability to debug routes (like loader data) through the TanStack Router DevTools, which you can integrate directly into your RootDocument component.

DevTools TanStack Router
DevTools TanStack Router

Deployment

Deploying a Start app is straightforward. Several presets are provided, which you can pass either in your config or as a flag during the build. For a basic Node server, for example:

yarn build
node .output/server/index.mjs

Start supports all major hosting providers, like Vercel and Netlify.

Conclusion

Overall, I’ve had a great experience using Start. The DX is excellent, the documentation is thorough (as with all TanStack libraries), and it includes all the features you’d expect from a full-stack framework. In my opinion, server functions are much better handled than in NextJS (with GET/POST methods and customizable responses). The router DevTools are also a huge plus. This is definitely a solution we’ll be paying close attention to—it’s a genuinely compelling alternative to NextJS. Got an idea for a project where TanStack Start would shine? Try our project estimator!

À découvrir également

AI et UI #1 - Filtres intelligents avec le SDK Vercel AI et Next.js

12 Jun 2024

AI et UI #1 - Filtres intelligents avec le SDK Vercel AI et Next.js

Dans ce premier article d’une série consacrée à l’IA et l’UI, je vous propose de découvrir différentes manières d’intégrer ces modèles d’IA dans vos applications React pour améliorer l’expérience utilisateur.

par

Baptiste

Retour d'expérience chez 20 Minutes : s'adapter au journalisme numérique

30 May 2024

Retour d'expérience chez 20 Minutes : s'adapter au journalisme numérique

Dans cet article, vous ne trouverez pas de snippets ou de tips croustillants sur React, seulement le récit d'une aventure plutôt ordinaire à travers le développement et l'évolution d'un projet technique.

par

Vincent

Améliorez vos composants avec Storybook

17 Nov 2020

Améliorez vos composants avec Storybook

Connaissez-vous Storybook ? Cet outil open-source offre un environnement...

par

Baptiste

Premier Octet vous accompagne dans le développement de vos projets avec react

En savoir plusNous contacter
18 avenue Parmentier
75011 Paris
+33 1 43 57 39 11
hello@premieroctet.com

Suivez nos aventures

GitHub
X
Flux RSS
Bluesky

Naviguez à vue