A few months ago I published a post titled Why I Chose Nuxt Over Next.js for AccessHawk. I meant every word. Vue's reactivity model is elegant. Nuxt's developer experience is really, really good. Auto-imports, composables, Nitro server routes - the whole stack just works.
So why did I just rebuild my entire portfolio site in React?
Short answer: TanStack Start is React done right. The problems I had with React were never really React problems - they were Next.js problems. TanStack Start strips away the opinions I disagreed with and gives me back control over the things that matter: the build pipeline, the middleware stack, and how my code actually ships to the browser.
What TanStack Start Actually Is
TanStack Start is a full-stack React framework built on Vite and Nitro. If those names sound familiar, it's because Nitro is the same server engine that powers Nuxt. File-based routing, server-side rendering, server functions - the feature set overlaps with Next.js in broad strokes, but the architecture underneath is different in ways that actually matter.
There's no black-box compiler. No "use client" directives. No middleware.ts single-file limitation. You get a Vite config you can actually read, a plugin system you can extend, and a server runtime that doesn't fight you.
Pre-Rendered MDX: Eliminating an Entire Vendor Bundle
This is my favorite architectural decision in the rebuild. Every blog post on this site is written in MDX - markdown with embedded JSX components, so I can mix prose with code blocks, callouts, and custom elements in the same file. The standard way to render MDX on the client uses mdx-bundler/client, which calls new Function(code) at runtime to evaluate the compiled output. That's a problem because this site runs a strict Content Security Policy with no 'unsafe-eval' in script-src. CSP blocks it.
The fix: render MDX to static HTML at build time. The MDX runtime runs in Node during the build (where eval is allowed), and only the resulting HTML string ships to the client.
// content-collections.ts (simplified)
import { compileMDX } from '@content-collections/mdx'
import { MDXContent } from '@content-collections/mdx/react'
import { renderToStaticMarkup } from 'react-dom/server'
const posts = defineCollection({
name: 'posts',
directory: 'content/blog',
include: '**/*.mdx',
schema: z.object({
title: z.string(),
slug: z.string(),
// ... other fields
}),
transform: async (document, context) => {
// Step 1: compile MDX to a runnable module (Node-side, eval OK)
const mdx = await compileMDX(context, document, {
remarkPlugins: [remarkGfm],
rehypePlugins: [[rehypeShiki, { themes: { light: '...', dark: '...' } }]],
})
// Step 2: render to static HTML - no client-side eval needed
const html = renderToStaticMarkup(
React.createElement(MDXContent, {
code: mdx,
components: staticComponentMap,
}),
)
return { ...document, html }
},
})The blog post route renders that HTML string via dangerouslySetInnerHTML (safe here - the HTML comes from our own MDX files at build time, not user input). The entire vendor-mdx chunk (the MDX runtime, the JSX evaluator, all of it) is gone from the client bundle. The MDX runtime, its dependencies, and the JSX evaluator never download on any page.
Beyond the bundle savings, this is a straight performance win over the old site. The previous Vue build hydrated blog content on the client - the browser had to parse, compile, and render the markdown on every page load. Now the server ships finished HTML. The browser just paints it.
The tradeoff: since there's no MDX runtime on the client, you can't embed interactive React components inside blog posts. For a dev blog that's mostly prose and code examples, I don't need that. The one interactive element I wanted - a copy button on code blocks - works fine as a vanilla JS enhancement that attaches after the page mounts.
Middleware That Actually Composes
Next.js gives you one middleware.ts file at the root. All your middleware logic lives there. Need security headers AND compression AND rate limiting? One file, one function.
TanStack Start treats middleware as composable units:
// start.ts
import { createStart } from '@tanstack/react-start'
import { compressionMiddleware } from './middleware/compression'
import { securityMiddleware } from './middleware/security'
export const startInstance = createStart(() => ({
requestMiddleware: [compressionMiddleware, securityMiddleware],
}))Each middleware is its own module. The array order controls execution. The next() pattern should feel familiar if you've used Express or Koa:
// middleware/security.ts
import { createMiddleware } from '@tanstack/react-start'
export const securityMiddleware = createMiddleware().server(
async ({ next }) => {
const result = await next()
result.response.headers.set(
'content-security-policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; ...",
)
result.response.headers.set(
'strict-transport-security',
'max-age=63072000; includeSubDomains; preload',
)
// ... other security headers
return result
},
)This is how server middleware should work. Separate concerns, separate files, clear execution order. Adding a new middleware means adding a file and appending to an array. No weaving more conditions into a growing monolith.
You Own the Build
The Vite config is yours. Not a wrapper around Webpack that hides the real config behind a next.config.js abstraction. The actual config.
This matters when you want to do things like split vendor bundles so your home page doesn't download code it doesn't need:
// vite.config.ts
export default defineConfig({
plugins: [
nitro({ compressPublicAssets: { gzip: true, brotli: true } }),
tsconfigPaths(),
contentCollections(),
tailwindcss(),
tanstackStart(),
viteReact(),
],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return undefined
const norm = id.replace(/\\/g, '/')
if (norm.includes('/shiki/') || norm.includes('/@shikijs/'))
return 'vendor-shiki'
if (norm.includes('/@mdx-js/') || norm.includes('/@content-collections/'))
return 'vendor-mdx'
if (norm.includes('/@tanstack/'))
return 'vendor-tanstack'
if (norm.includes('/react/') || norm.includes('/react-dom/'))
return 'vendor-react'
return undefined
},
},
},
},
})The result: a visitor on the home page never downloads Shiki (syntax highlighting) or MDX vendor code. Those only load when you navigate to a blog post. Build-time brotli compression via Nitro's compressPublicAssets handles static assets, and a runtime middleware compresses the SSR HTML. Same approach Nuxt uses internally, because it's the same server engine.
How It Actually Performs
I ran Lighthouse against the production builds of both the old Vue/Nuxt site and the new TanStack Start site. Here are the home page results:
Desktop:
| Metric | TanStack Start | Vue/Nuxt |
|---|---|---|
| Performance | 100 | 100 |
| Accessibility | 100 | 100 |
| SEO | 100 | 92 |
| LCP | 570 ms | 586 ms |
| Speed Index | 578 | 958 |
Mobile:
| Metric | TanStack Start | Vue/Nuxt |
|---|---|---|
| Performance | 72 | 78 |
| Accessibility | 100 | 94 |
| SEO | 100 | 92 |
| LCP | 6.2 s | 5.9 s |
| FCP | 2.7 s | 1.7 s |
The TanStack Start build is not dramatically faster by Lighthouse numbers. Desktop is a wash at 100/100. Mobile, the Vue build actually edges ahead in raw performance score. Total byte weight is higher on the new site too.
So where does the speed feeling come from?
Intent-based prefetching. TanStack Router prefetches routes on hover and touch start - before the user clicks or taps. By the time the click fires, the data and code are already loaded. Lighthouse can't measure this because it tests cold page loads, but in actual use the navigation feels instant:
const router = createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
})That single config line makes every internal link anticipate the user's next action. It's not faster on paper. It's faster in the hand.
The measurable wins are elsewhere: perfect accessibility (100 vs 94 - six points gained through semantic HTML fixes, color contrast improvements, and proper landmark structure), perfect SEO (100 vs 92), and a 40% faster desktop Speed Index (578 vs 958). The new site gets meaningful content on screen faster even though the total payload is larger.
The Right Tool for the Job
Vue is still a great framework. Nuxt is still a great meta-framework. I'd reach for either again on the right project - especially anything with complex reactive state where Vue's proxy-based reactivity is a better fit than React's model.
But for this site - a content-heavy portfolio with a blog, strict security requirements, and a build pipeline that needs to do unusual things like pre-rendering MDX at compile time - TanStack Start was the right call. I got React's ecosystem without Next.js's opinions, full control over Vite without ejecting from a framework, and Nitro's server engine without leaving the React world.
If you're a React developer frustrated with the direction Next.js is going, TanStack Start is worth a serious look. The ecosystem was still building out as of April 2026 — confirm the current state before adopting on a larger project. But it lets you make your own architectural decisions, and for me that made all the difference.
