18 juin 2025
TanStack Start: the Future of React Frameworks?

12 minutes reading

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:
{
"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()
],
},
})
Let’s update the scripts in our 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.
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:
@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.
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.
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>
);
}
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 the page on 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;
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 theRoute
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;
});
export const posts = Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
title: "Lorem ipsum dolor sit amet.",
content: '....lorem ipsum',
}));
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: "/",
});
},
);
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",
// Using cookies here; you can configure cookie options.
// By default, session storage is used.
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>
);
}
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:
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>
);
}
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>
);
}
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:
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:
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();
});
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;
});
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:
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.

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!