This site ran on Next.js 16 — React 19, App Router, next/font, next/image, next-themes, nuqs, fumadocs for MDX. It worked fine, but the framework was doing a lot of heavy lifting for what's ultimately a personal site with a handful of pages and a blog.
I ripped out Next.js and replaced it with Vite 8 + TanStack Start + Nitro. Here's how it went.
The Problem: Why Leave Next.js
Next.js is a great framework. But for a personal site, it was overkill in a few specific ways:
Config sprawl. My next.config.mjs had grown to handle rewrites (Umami analytics proxy), security headers (CSP, X-Frame-Options, Referrer-Policy), image remote patterns, and the fumadocs MDX plugin — all interleaved in a single export:
// next.config.mjs — 80 lines of framework-specific config
import { createMDX } from "fumadocs-mdx/next";
const withMDX = createMDX();
const config = {
reactStrictMode: true,
serverExternalPackages: ["@takumi-rs/image-response"],
images: {
remotePatterns: [
{ protocol: "https", hostname: "discord.com", pathname: "/**" },
{ protocol: "https", hostname: "i.scdn.co", pathname: "/**" },
],
},
async rewrites() {
return [
{ source: "/stats/script.js", destination: "https://cloud.umami.is/script.js" },
{ source: "/stats/api/send", destination: "https://cloud.umami.is/api/send" },
];
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()" },
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self' 'unsafe-inline' ..." },
],
},
];
},
};
export default withMDX(config);Framework-specific wrappers everywhere. next/font/google for fonts, next/image for images, next-themes for dark mode, nuqs for URL state, next/script for analytics. Each one is a thin wrapper around a standard web API, but each locks you into Next.js's way of doing things.
Build overhead. Next.js bundles its own Rust-based compiler (SWC), route analysis, static optimization detection, and image optimization pipeline. For a site with 5 pages and a blog, that's a lot of machinery.
This isn't an anti-Next.js post. Next.js is the right choice for production apps with teams, complex data fetching, and middleware requirements. But for a personal site, I wanted something closer to the metal.
Why Vite + TanStack Start + Nitro
Three tools, each doing one thing well:
-
Vite 8 — Native ESM dev server with HMR. No custom compiler, no framework magic. The config is a plain
vite.config.tswith composable plugins. -
TanStack Start — Type-safe file-based routing with
head()functions for metadata. Built on Vite, which I was already using in other projects. No framework-specific<Head>component orMetadataexport — just a function that returns meta tags. -
Nitro — Universal server layer with route rules for headers, proxying, ISR/SWR, and deployment presets (Vercel, Cloudflare, Node, etc.). All the server config that was scattered across
next.config.mjsmoves into declarativerouteRules.
The Migration
Step 1: Build Pipeline Swap
// Before (Next.js)
{
"dev": "next dev",
"build": "next build",
"start": "next start",
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
"postinstall": "fumadocs-mdx"
}
// After (Vite + TanStack Start)
{
"dev": "vite",
"build": "vite build",
"start": "node .output/server/index.mjs",
"types:check": "tsc --noEmit"
}The entire vite.config.ts that replaced next.config.mjs:
// vite.config.ts
import { defineConfig } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { nitro } from "nitro/vite";
import mdx from "fumadocs-mdx/vite";
import * as SourceConfig from "./source.config";
export default defineConfig({
server: { port: 3000 },
resolve: {
alias: {
// fumadocs-mdx runtime uses node:path in the client bundle
"node:path": "./src/lib/path-shim.ts",
},
},
plugins: [
tailwindcss(),
tsConfigPaths(),
tanstackStart({ prerender: { crawlLinks: true } }),
viteReact(),
mdx(SourceConfig),
nitro({
preset: "vercel",
routeRules: {
"/**": {
headers: {
"X-Frame-Options": "SAMEORIGIN",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
// ... CSP, Permissions-Policy
},
},
"/resume": { isr: 3600 },
"/api/resume": { swr: 3600 },
"/stats/script.js": { proxy: "https://cloud.umami.is/script.js" },
"/stats/api/send": { proxy: "https://cloud.umami.is/api/send" },
},
}),
],
});The config reads top-to-bottom: Tailwind, path resolution, routing, React, MDX, server. Each plugin is independent.
Step 2: Routing — App Router → TanStack Router
Next.js App Router uses filesystem conventions (page.tsx, layout.tsx, loading.tsx) and magic exports (metadata, generateStaticParams). TanStack Router uses createFileRoute with explicit head() and component properties:
// Before: src/app/page.tsx (Next.js)
import type { Metadata } from "next";
export const metadata: Metadata = buildMeta({
title: siteConfig.name,
description: siteConfig.description,
path: "home",
canonicalPath: "/",
type: "profile",
});
export default function Page() {
return (
<>
<Hero />
<WorkExperience />
<Contact />
</>
);
}// After: src/routes/index.tsx (TanStack Router)
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
head: () => {
const { meta, links } = buildHead({
title: siteConfig.name,
description: siteConfig.description,
path: "home",
canonicalPath: "/",
type: "profile",
});
return { meta, links };
},
component: HomePage,
errorComponent: RouteError,
});
function HomePage() {
return (
<>
<Hero />
<WorkExperience />
<Contact />
</>
);
}The head() function returns { meta, links } arrays instead of a Metadata object. More verbose, but also more explicit — you see exactly what tags get rendered.
Step 3: API Routes — Route Handlers → Server Handlers
Next.js API routes export named HTTP method functions. TanStack Start uses server.handlers inside route definitions:
// Before: src/app/api/resume/route.ts (Next.js)
import { NextResponse } from "next/server";
export async function GET() {
const res = await fetchResume();
return new NextResponse(res.body, {
headers: {
"Content-Type": "application/pdf",
"Cache-Control": "public, max-age=3600",
},
});
}// After: src/routes/api/resume.ts (TanStack Start)
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/api/resume")({
server: {
handlers: {
GET: async () => {
const res = await fetchResume();
return new Response(res.body, {
headers: {
"Content-Type": "application/pdf",
"Cache-Control": "public, max-age=3600",
},
});
},
},
},
});Standard Response instead of NextResponse. The route definition and the handler live in the same object.
Step 4: SSR/Caching — next.config → Nitro routeRules
Next.js scatters caching config across next.config.mjs (rewrites, headers), route segment configs (revalidate, dynamic), and fetch() options. Nitro puts it all in one place:
routeRules: {
"/resume": { isr: 3600 }, // ISR with 1-hour revalidation
"/api/resume": { swr: 3600 }, // Stale-while-revalidate
"/stats/script.js": { proxy: "https://cloud.umami.is/script.js" },
"/stats/api/send": { proxy: "https://cloud.umami.is/api/send" },
}Four lines replace the rewrites() function and the per-route caching configuration.
Step 5: Metadata — Metadata API → head() Functions
Next.js exports a Metadata type with a specific shape (title, description, openGraph, twitter, etc.). TanStack Start's head() returns raw <meta> and <link> tag arrays:
// Root layout head (TanStack Start)
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: `${siteConfig.name} — ${siteConfig.role}` },
{ name: "description", content: siteConfig.description },
{ name: "theme-color", content: "#ffffff", media: "(prefers-color-scheme: light)" },
{ name: "theme-color", content: "#09090b", media: "(prefers-color-scheme: dark)" },
],
links: [
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
{ rel: "apple-touch-icon", href: "/apple-icon.png" },
],
}),No abstraction layer. What you write is what gets rendered in <head>.
Step 6: Images & Fonts — next/image → <img>, next/font → @fontsource
Images: next/image was replaced with standard <img> tags. For a personal site with a handful of images, the automatic optimization wasn't worth the framework coupling. I pre-optimized the profile image manually (WebP, sized correctly) and the LCP performance stayed comparable.
Fonts: next/font/google was replaced with @fontsource-variable packages and manual preload links:
// Before: next/font/google (layout.tsx)
import { Plus_Jakarta_Sans, Geist_Mono, Noto_Sans_Devanagari } from "next/font/google";
const plusJakartaSans = Plus_Jakarta_Sans({
subsets: ["latin"],
display: "swap",
variable: "--font-plus-jakarta-sans",
});// After: @fontsource-variable + preload (__root.tsx)
import plusJakartaSansLatin from
"@fontsource-variable/plus-jakarta-sans/files/plus-jakarta-sans-latin-wght-normal.woff2?url";
// In head():
links: [
{
rel: "preload",
href: plusJakartaSansLatin,
as: "font",
type: "font/woff2",
crossOrigin: "anonymous",
},
],Self-hosted fonts with explicit preload hints. No Google Fonts network request, no render-blocking CSS.
Step 7: Theme & Providers — next-themes → tanstack-theme-kit
next-themes depends on Next.js internals. Replaced it with tanstack-theme-kit, which provides the same useTheme() hook without the framework dependency.
The fumadocs provider also changed from the Next.js-specific export to the framework-agnostic one:
// Before
import { RootProvider } from "fumadocs-ui/provider/next";
// After
import { RootProvider } from "fumadocs-ui/provider/tanstack";Step 8: MDX/Content
The fumadocs MDX plugin also changed from the Next.js integration to the Vite one:
// Before (next.config.mjs)
import { createMDX } from "fumadocs-mdx/next";
const withMDX = createMDX();
export default withMDX(config);
// After (vite.config.ts)
import mdx from "fumadocs-mdx/vite";
import * as SourceConfig from "./source.config";
// In plugins array:
mdx(SourceConfig),Step 9: Dependency Cleanup
Removed (Next.js-specific):
next(16.1.6)next-themesnuqs(Next.js URL state adapter)@vercel/analytics→ switched to@vercel/analytics/react
Added (Vite + TanStack):
@tanstack/react-start,@tanstack/react-router,@tanstack/zod-adaptervite,@vitejs/plugin-react,vite-tsconfig-pathsnitro(3.0-beta)@fontsource-variable/plus-jakarta-sans,@fontsource-variable/geist-mono,@fontsource-variable/noto-sans-devanagaritanstack-theme-kitpath-browserify
Net dependency count stayed roughly the same, but the dependencies are now standard Vite ecosystem packages rather than Next.js-specific wrappers.
The Numbers
Build Performance
Benchmarked on an M3 MacBook Pro, median of 3 runs, clean node_modules each time:
| Metric | Next.js 16 | Vite + TanStack | Change |
|---|---|---|---|
| Clean install | 9.5s | 9.8s | ~same |
| Build time | 8.7s | 4.1s | 53% faster |
| Build output | 22 MB | 6.5 MB | 70% smaller |
Install time is effectively the same. The build is roughly twice as fast, and the output is a third of the size.
Gotchas
A few things bit me during the migration:
node:path polyfill. fumadocs-mdx's runtime imports node:path in code that runs in the browser bundle. Vite doesn't polyfill Node built-ins by default (unlike Next.js/webpack). The fix is a small shim:
// src/lib/path-shim.ts
import path from "path-browserify";
export const join = path.join;
export const resolve = path.resolve;
export const dirname = path.dirname;
export const basename = path.basename;
export const extname = path.extname;
export const sep = path.sep;
export const delimiter = path.delimiter;
export default path;Then alias it in vite.config.ts:
resolve: {
alias: { "node:path": "./src/lib/path-shim.ts" },
},fumadocs import paths. The fumadocs packages have separate entry points for Next.js and framework-agnostic use. fumadocs-ui/provider/next becomes fumadocs-ui/provider/tanstack, fumadocs-mdx/next becomes fumadocs-mdx/vite. Easy to miss, cryptic errors when wrong.
No next/image replacement. There's no drop-in Vite equivalent of next/image with automatic resizing, format conversion, and lazy loading. For a personal site this doesn't matter — I pre-optimized images manually. For image-heavy sites, this is a real gap.
Nitro is in beta. The Vite plugin is nitro@3.0.260311-beta. It works, but APIs may change. I'm comfortable with this for a personal site; I wouldn't use it in production at work yet.
TanStack Start is pre-1.0. The API has been stable for months, but there's no generateStaticParams equivalent — prerendering relies on crawlLinks: true to discover pages. Dynamic routes that aren't linked from the crawl root won't be prerendered.
Conclusion
The migration took about two days. Most of the time went into font loading (getting @fontsource-variable + preload to match next/font's behavior), the node:path polyfill discovery, and fumadocs import path changes.
For a personal site, composable tools (Vite + TanStack + Nitro) feel better than a monolithic framework. Each piece is replaceable. The config is readable. The build is fast and the output is small.
The honest caveats: TanStack Start and Nitro are both pre-stable. The ecosystem is smaller — no equivalent of next/image, fewer deployment guides, less Stack Overflow coverage. If you need ISR, middleware, or server components today, Next.js is still the more battle-tested choice.
But if you're building a personal site and you want to understand every piece of your stack — rather than trusting a framework to handle it — the Vite + TanStack Start stack is worth considering. The migration path from Next.js is mechanical, not architectural.
Disclaimer: Claude/AI was used to help write this blog post.