TanStack Start for internal tools: when you don't need Next.js
Every custom build ships two frontends — the one your customers see and the one only your client's own staff opens. The first earns Next.js. The second is usually paying a tax for machinery it never uses.
Every custom web application we ship for a client tends to come in two frontends, not one. There's the public one — a storefront, a booking flow, a marketing shell — and there's the private one nobody outside the client's own staff ever opens: the ops dashboard, the internal tool that edits what the ERP can't expose to a browser directly, the admin panel bolted onto Odoo for a process Studio can't model. Both are React. Only one of them should default to Next.js.
We used to reach for Next.js on both, because it's the framework every developer on the team already knows and the App Router is a genuinely good default for public pages. But an internal tool doesn't have an audience to optimise for beyond the twelve people who use it every day, behind SSO, on a known network. It never needs to rank on Google, never gets crawled, never benefits from generateStaticParams or an edge cache. It just needs to be fast to build, fast to change, and honest about the fact that its only backend is the same API layer everything else already talks to. For that shape of app, TanStack Start is what we reach for now.
What Next.js is buying you — and who's paying for it
The App Router earns its complexity on a storefront: metadata per route for SEO, generateStaticParams for pages that exist in four languages, ISR so a catalogue update doesn't need a redeploy, an edge network that makes time-to-first-byte a non-issue for an anonymous visitor in another country. None of that is free — it's file conventions, route groups, loading.tsx boundaries, careful client/server component boundaries — but a public site pays that tax once and every visitor benefits.
An internal ops dashboard has none of those visitors. Nobody is indexing the picking-list screen. Nobody in another country needs it to render in 200ms off an edge node — the client's own office network already does that. But the app still inherits every App Router convention, because that's what the framework is. You still decide what's a Server Component and what needs "use client". You still reach for a Server Action or a Route Handler to talk to your backend, and you still hand-wire the types between the two because Next.js doesn't infer them for you. The machinery built for a million anonymous visitors is still there when your only visitor is the warehouse team, and somebody still has to pay for it in build time and mental overhead.
What TanStack Start actually is
TanStack Start is a full-stack React framework built on Vite and TanStack Router — the same router underneath the type-safe hover-prefetch we mentioned in our TanStack Query post. Routes are files, like Next.js, but the router itself is fully typed: params, search params and loader data all flow through inference instead of string | undefined casts scattered across the codebase.
The part that actually changes how we build internal tools is createServerFn. It's a plain async function that only ever runs on the server, callable directly from client code like it was a local import:
import { createServerFn } from "@tanstack/react-start"
import { z } from "zod"
import { erpApi } from "~/lib/erp-api"
export const getPickingList = createServerFn({ method: "GET" })
.validator(z.object({ warehouseId: z.number().int().positive() }))
.handler(async ({ data }) => {
// erpApi talks to our NestJS layer — never to Odoo JSON-RPC directly,
// same rule as every other consumer.
return erpApi.get(`/warehouses/${data.warehouseId}/picking-list`)
})No separate route handler, no hand-written fetch call on the client, no shared types.ts file the two sides quietly drift out of sync on. The zod validator runs on the server where you can trust it, and the return type flows straight into whatever calls getPickingList() — client component, loader, another server function, doesn't matter.
Server functions instead of Server Actions
Next.js Server Actions do something similar, but they're built around form submission first and general RPC second: a "use server" directive, extra care about where the function lives so it doesn't get bundled client-side, and a mental model borrowed from <form action={...}> even when what you actually want is "call this function and get JSON back." It works, but it's clearly a form-mutation primitive stretched to cover request/response.
createServerFn skips the stretch. It's a request/response primitive from the start, so calling it from a click handler, a loader, or a mutation reads the same way every time:
import { createFileRoute } from "@tanstack/react-router"
import { getOrder, cancelOrder } from "~/server/orders"
export const Route = createFileRoute("/orders/$orderId")({
loader: ({ params }) => getOrder({ data: { orderId: params.orderId } }),
component: OrderDetail,
})
function OrderDetail() {
const order = Route.useLoaderData()
return (
<button onClick={() => cancelOrder({ data: { orderId: order.id } })}>
Cancel order
</button>
)
}For an internal tool this is most of the app: fetch something from the API layer, render it, mutate it, refetch. There's no framework ceremony standing between "I need this data" and "here it is, typed."
The prefetch we already told you about
We flagged this in passing in the TanStack Query post and it's worth spelling out here: TanStack Start prefetches loader data on hover by default. The router that ships loader-based data fetching is the same router that decides when a link is about to be clicked, so the two are wired together out of the box — no onMouseEnter handler to write yourself.
import { Link } from "@tanstack/react-router"
<Link to="/orders/$orderId" params={{ orderId: order.id }}>
{order.reference}
</Link>Point that loader at the same query key factory your TanStack Query setup already uses — Start's router hands you the queryClient through router context — and hovering a row in the orders table warms the exact cache entry the detail page reads from. The click opens from cache. It's the same discipline we described for ERP-backed React apps, just without the manual wiring.
Where we still reach for Next.js
None of this makes Next.js the wrong choice — it makes it the wrong default for this one category of app. We still put every public-facing, SEO-relevant, high-traffic frontend on Next.js:
- Anything Google needs to see. Metadata routing, sitemaps and the App Router's static generation story are more mature than Start's today, and for a storefront or marketing site that maturity is the whole point.
- Anything that benefits from an edge network of anonymous visitors. ISR and edge caching solve a problem internal tools don't have, because internal tools don't have anonymous traffic.
- Anything the rest of the team will maintain for years. Next.js is the default every new hire already knows. An internal tool used by three people can absorb a second framework in the stack; the storefront your whole team touches weekly shouldn't have to.
The split isn't "Start is better." It's "match the framework to who's actually going to load the page."
The shape it ends up taking
In practice this means a client engagement can carry two frontends against one backend: a Next.js storefront for customers, a TanStack Start app behind SSO for the client's own staff, both calling the same NestJS API layer that already sits in front of Odoo. Neither frontend talks JSON-RPC directly — that rule doesn't change because the framework did. The internal app just gets there with less: a beforeLoad check on the route tree instead of middleware plus a session read in three different places, a handful of createServerFn calls instead of a parallel set of Route Handlers, and no App Router conventions paying rent on pages nobody outside the building will ever open.
The rule of thumb
Ask who actually loads the page. A customer, a prospect, a search crawler — Next.js, no debate, the App Router earns every bit of its complexity there. Three people on your client's ops team who are already logged in — TanStack Start, and the app is smaller and faster to change for it. Both still speak to the one API layer we'd insist on either way, so picking the lighter framework for the internal half costs you nothing on the backend and saves real time on the front.
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.