Skip to main content

TanStack Start: New competitor to Next.js

· 17 min read

I've been using TanStack Start for a mid-size hobby webapp since its early alpha stage—over a year now. I'm starting to feel I have enough experience, and since Start just entered a release candidate stage, it's time to share my insights with you.

I will present Start by comparing it to Next.js.

Both are great pieces of software. In some cases very similar. In other cases very different.

Notes:

  • I assume you have basic knowledge of the Next.js App Router (docs).
  • This article is heavily inspired by my talk at FrontKon 2025 in Prague (🎥 YouTube Czech only).
  • Actually, I compare TanStack Start (docs) + TanStack Router (docs) and reference both as just "Start" for simplicity. It feels like a natural comparison since Next.js also includes a router.

 

What do I compare?

I will compare Start to Next in the following areas. I chose these because, from my point of view, this is where the two frameworks differ the most.

  • Routing - Typesafety
  • Data fetching - SSR, Streaming
  • Performance - Bundle size, Caching

On the other hand, I will not compare:

  • Non-technical differences
    • Like production readiness, Vercel lock-in, community, ecosystem, ...
    • Because I like to dig the technical differences.
  • Areas where they are very similar
    • Server actions/functions, progressive enhancement, SEO, hosting, ...
    • Because there are mostly syntactical differences, and I'm not interested in these.

 

Routing

 

Route definition

Both frameworks support file-based routing. You define your pages based on your file/folder structure and naming conventions. There are some minor differences. Like Next using page.tsx and Start using index.tsx files for page definition. Or [postId] folder in Next and $postId in Start as a dynamic route segment. Just syntactic sugar. No big difference here.

Next Logo
📁 blog
├─ 📁 [postId]
│ └─ ⚛ page.tsx
└─ ⚛ page.tsx
Start Logo
📁 blog
├─ 📁 $postId
│ └─ ⚛ index.tsx
└─ ⚛ index.tsx

Moving on. Start allows you to define so-called flat-route definitions. This means you can define your path in a file name instead of a folder. For example, a single file in the root folder /blog.$postId.tsx in Start will define the same route as /blog/$postId/page.tsx in Next. From my experience, it is often useful and readable, especially when you use it in combination with classic nested folders definitions.

Start Logo
Flat-route definition
⚛ blog.tsx
⚛ blog.$postId.tsx
Start Logo
Combined flat-route with nested folders
📁 blog
├─ ⚛ $postId.tsx
└─ ⚛ index.tsx

 

Page config

Now let's look inside a page file. Next.js uses an extremely brief and readable syntax. Love it.

Next Logo
/blog/page.tsx
export default function Blog() {
return <div/>;
}

Start is a little more complicated. We need to use the createFileRoute(...) function. Worth pointing out its first argument /blog. It's the route identifier and is used internally to infer TypeScript types. What's convenient is that the first line is auto-generated and the route identifier is derived from the file name by Start dev server. This way it prevents typos and misalignments between the file name, route identifier and type inference.

Start Logo
/blog/index.tsx
export const Route = createFileRoute("/blog")({ // this is auto-generated
component: Blog,
});

function Blog() {
return <div/>;
}

 

Router typesafety

Now let's look at how the dynamic route segment parameter postId is used. In Start, it's pretty simple—just use the useParams hook. It's worth mentioning that useParams is typesafe, and your IDE will suggest available param names and catch typos.

Start Logo
/blog/$postId.tsx
export const Route = createFileRoute("/blog/$postId")({
component: BlogPost,
});

function BlogPost() {
const { postId } = Route.useParams();
return <div>{postId}</div>;
}

Let's look at how it is done in Next. See that it also needs a route identifier to infer types. This way, params are also typed. Note that this only works if you use the typedRoutes feature (docs). Without it, there's no type inference, your IDE won't offer suggestions, and AI agents won't benefit from it either.

Next Logo
/blog/[postId]/page.tsx
export default async function BlogPost(
{ params }: PageProps<"/blog/[postId]">
) {
const { postId } = await params;
return <div>{postId}</div>;
}

I love how competition is driving change here. When TanStack Router came out with its awesome and convenient end-to-end typesafe system, developers loved it. Quickly it forced Next.js and Remix to invest in it too. In version 15.5, Next.js introduced a new typedRoutes feature (blog post). It is not enabled by default, and I highly suggest enabling it. It's a great DX improvement; however, you might be surprised that it is not as reliable as you would expect. Let's see some examples.

Firstly, a <Link/> component. As you can see, Next can't infer routes with dynamic segments, and therefore your IDE is not able to suggest all available routes. Inconvenient for devs and from my experience it confuses AI agents too.

Next Logo
<Link href="">Blog Post</Link>
// ⬑ IDE suggests:
// ┏━━━━━━━━━┓
// ┃ / ┃
// ┃ /blog ┃
// ┗━━━━━━━━━┛
Start Logo
<Link to="">Blog Post</Link>
// ⬑ IDE suggests:
// ┏━━━━━━━━━━━━━━━┓
// ┃ / ┃
// ┃ /blog/ ┃
// ┃ /blog/$postId ┃
// ┗━━━━━━━━━━━━━━━┛

Another example. Next does not URL-encode dynamic parameters. In this case, Next renders a <a href="/blog/"> tag referencing the blog page. Allowing it, even if route /blog/ is not defined anywhere in our app. Type checking passes, and users end up on a 404 page.

Next Logo
<Link href="/blog/ ">
Blog Post
</Link>

Start correctly encodes the space and renders a <a href="/blog/%20"> tag pointing to the blog post page with postId being a space (encoded as %20). I know it's a rare case, but it does make me keep checking for correctness in Next.

Start Logo
<Link to="/blog/$postId" params={{ postId: " " }}>
Blog Post
</Link>

And in my opinion, the biggest Next flaw: it does not type search query params. At all. You are on your own. Start has you covered. It even supports validation libraries like Zod for more complex data structures (docs).

Start Logo
export const Route = createFileRoute("/blog")({
component: Blog,
validateSearch: z.object({
postId: z.string().optional(),
}),
});
<Link 
to="/blog"
search={{ postId: "123" }}
>
Blog Post
</Link>

 

 

To summarize: Start is more flexible in defining routes. It supports both file-based and code-based routing, as well as flat-config definition. Start forced Next to introduce the typedRoutes feature. Turn it on if you use Next. However even with it enabled, Next is not 100% typesafe. Start is, and it is very convenient for both devs and AI agents. (Here it may seem I'm talking about Start advantages and Next flaws only. But later I also highlight the opposite. Keep reading :)

 

Data fetching

 

Collocation

This is how Next.js loads data. Just use an async component and await within the component body. Extremely convenient.

Next Logo
async function Blog() {
const posts = await getPosts();
return posts.map(() => <>...</>);
}

In Start's documentation, the recommended way is to use the Route loader.

Start Logo
export const Route = createFileRoute("/blog")({
component: Blog,
loader: async () => await getPosts(),
});

function Blog() {
const posts = Route.useLoaderData();
return posts.map(() => <>...</>);
}

Personally, I do not like this pattern. It is more verbose. But what's more important than syntax is that the component becomes dependent on a route. The Blog component cannot be easily used elsewhere in the app. I always have to remember to update the parent route loader as well. In the case of a Blog component, it is quite simple, but imagine a deeper and more reusable component like an <AuthorAvatar/> with a tooltip. It can be used on a blog post page, then extended to a list of posts, list of authors, author details page, favourite posts, etc. In all those places, I would need to remember to update the parent route loader.

Therefore, I recommend integrating TanStack Start with TanStack Query. The data loading example would then look like this.

Start Logo
function Blog() {
const posts = useSuspenseQuery({
queryKey: ['posts'],
queryFn: () => getPosts(),
});
return posts.data.map(() => <>...</>);
}

It's still more verbose than Next, but we get rid of the route dependency.

You may think that this way it stops being prefetched and may create waterfalls. And you'd be correct. However useSuspenseQuery can opt-in to being prefetched. Both frameworks do it similarly by hoisting the query to the top of the route. Next uses void getPosts() syntax, Start uses queryClient.fetchQuery(...). I will not go into details. Let's just say both frameworks are very similar.

 

Streaming

There is another way to compare Next and Start data fetching: performance. Let's look at the request timeline of Next request with awaited data fetching.

Next Logo
Request
Server
Fetch
Server
Network
Client
FCP+LCP

You can see that awaiting data in Next.js blocks rendering on the server during the fetch. The FCP metric suffers. Start does the same. It's actually even a little worse because it typically needs to transfer more data over the network to the client. Because Start does not support React Server Components (yet, blog post), it has to transfer more JS components together with data needed to hydrate them.

Start Logo
Request
Server
Fetch
Server
Network
Client
FCP+LCP

Fortunately, both frameworks support streaming. Both adopted React's <Suspense/> component, and luckily for us, without any configuration. It's enough to just wrap our component within <Suspense/>, and the content will be streamed, greatly improving both FCP and LCP.

Next Logo
Start Logo
<Suspense>
<Blog />
</Suspense>
Next Logo
Start Logo
Request
Server
Network
Client
FCP
Fetch
Server
Network
Client
LCP

 

Fetching with parameters

Often you need to pass some parameters to the fetching endpoint, like postId. We already covered how to get route params in the Routing section. Now let's just combine it with fetching.

In Start, we can simply use the useParams hook directly in the component and pass postId to the query. The syntax is a little verbose, but I love the collocation of code and component independence.

Start Logo
function BlogPost() {
const { postId } = Route.useParams();
const post = useSuspenseQuery({
queryKey: ['posts', postId],
queryFn: () => getPost(postId),
});
return <div>{post.title}</div>;
}

In Next.js, we access the params prop in the page component. Seems pretty straightforward. But there is a catch. Do you see it?

Next Logo
// /blog/page.tsx
export default async function BlogPost(
{ params }: PageProps<"/blog/[postId]">
) {
const { postId } = await params;
const post = await getPost(postId);
return <div>{post.title}</div>;
}

In Next, we can only get params prop in the root page component. params are not accessible in nested components. This can cause problems. Imagine the <AuthorAvatar/> component again, which consumes the postId param. It will be nested somewhere deep in the component tree.

We need to drill postId through each component of the tree until we reach the <AuthorAvatar/> component. Your first idea is probably to use React Context to avoid prop drilling. But it is not allowed in server components. It throws Error: React Context is unavailable in Server Components. Ouch. We either need to prop drill, use client components, or refactor our component structure. Either way, it is a lot of work for such a common task.

Next Logo
<BlogPostPage/>
⬇ postId
<PostContent/>
⬇ postId
<PostHeader/>
⬇ postId
<PostAuthor/>
⬇ postId
<AuthorAvatar/>

 

 

To summarize data fetching: I recommend integrating Start with TanStack Query for better code collocation, component reusability, and independence. Both Next and Start support streaming via <Suspense/> out of the box. And both frameworks can avoid network waterfalls similarly. Next uses more concise syntax, but it is not as ergonomic when accessing params in nested components.

 

Performance

 

Bundle size

So far, it looks like TanStack Start is overall better than Next. But here Start loses. Start does not support React Server Components yet (blog post), and therefore all code is bundled and shipped to the client—even parts that are static and don't update on the client. Next allows you to select which components go to the client and which ones stay server-only. Server-only is the default and component is sent to the client only when marked with "use client". It can make the client bundle smaller. This means less code is shipped to the client, resulting in less network transfer, faster hydration time, and therefore faster TTI.

Next Logo
Request
Server
HTML doc
Processing HTML
FCP
Server
Bundle transfer
Hydration
TTI
Start Logo
Request
Server
HTML doc
Processing HTML
FCP
Server
Bundle transfer
Hydration
TTI

Depending on how static your app content is, this can have a significant impact on performance. Let's briefly go through the examples of a static and a dynamic component.

A static component could be a <NavigationBar/> or an <ArticleContent/>. You can still update them sometimes, but it is probably fine if they are stale for a few minutes or even hours after the update.

A dynamic component could be a <CommentSection/> or a <LikesCounter/>. It would be a problem if a user sent a comment or pressed a like button but needed to wait several minutes or hours to see it reflected on the page.

That being said, Next has quite a performance advantage in highly static apps—or what some would call websites.

 

Caching and Prerendering

Next defines 4 layers of caching (docs). Start fully supports 3 of them. Full Route Cache is supported partially. Let's talk about it.

Cache layerWhat it cachesPurposeNext LogoStart Logo
Request MemoizationReturn values of functionsRe-uses data in a React Component tree on a server during one request🟢🟢
Data CacheDataStores data across user requests and deployments🟢🟢
Full Route CacheHTML and RSC payloadReduces server rendering effort for each request🟢🟠
Router CacheRSC PayloadReduces server requests on navigation🟢🟢

You might know Full Route Cache under other names like Prerendering or Static Generation. The main idea is to render a page once—either during build time or during the first request—store the rendered HTML, and serve it from cache on subsequent requests. This way, the best FCP is achieved. Both Start and Next support this feature. Next has it enabled by default; Start is opt-in (docs). Next has an easier way to invalidate cache. As far as I know, Start needs to rebuild all pages, which is not a problem for small apps, but for large ones it can take a while and is annoying. Nevertheless, for the end user, the experience and performance are equal.

Next Logo
Start Logo
Build
Request
Server
Fetch
Server
Server
Network
Client
FCP

In the real world however, a lot of pages are not fully static or fully dynamic. They contain some static and some dynamic parts—components. Imagine a blog post page. It contains a header with author avatar and name, article content, and a comments section. <Author/> and <Article/> are static, but because of the <Comments/> component, we would not be able to prerender and cache the page.

            <BlogPostPage/>
┌───────────┼────────────┐
<Author/> <Article/> <Comments/>

Next solves this problem with Cached Components (docs, previously called Partial Prerendering). This way, some components (called shell) are rendered during build and sent to the client immediately when requested, while the server fetches dynamic data, renders the rest, and streams it to the client. This way, we can prerender the <Author/> and <Article/> components and stream the <Comments/> section during the request. Start does not provide any support for Cached Components.

Next Logo
Build
Request
Server
Fetch
Server
Server
Network
Client
FCP
Fetch
Server
Network
Client
LCP

 

 

To summarize: Start has a lot of performance optimizations, but Next wins, especially for highly static websites. Next supports RSC, which results in a smaller bundle and faster TTI for end users. Next supports Cached Components, which improves FCP for static pages with some dynamic parts.

 

Summary

So which one is better? Next or Start?

Next LogoConcise syntax that feels productive
Flexible route definition with flat-configStart Logo
Next LogoSmaller bundle size and therefore better TTI
Advanced router typesafety that I can rely onStart Logo
Next LogoGranular prerendering and therefore better FCP
Ergonomic access to path params with better collocationStart Logo

Both are trying to catch up with each other. But if I had to simplify and answer in a single sentence:

Next.js app is more performant for end users
TanStack Start app is easier to develop and maintain

Currently, I would recommend using Next for mostly static websites where its performance-focused approach can outshine Start, or where site performance has a high impact on your business—for example, when you know every 100 ms of latency costs you $$$ in revenue.

In all other cases, I would recommend using TanStack Start. It's performant enough for most cases, and due to its better DX, it can save you time during development and maintenance. And it scales for bigger apps.

Lastly, I want to say I love seeing the frontend market grow and competition pushing and inspiring each other to improve.

Thank you for reading.