We rolled our own Next

J
John Doe
|
Jan 15, 2024
|
5 min read
#react
#javascript
#frontend

Giant Context builds and hosts websites. The website is the product, public, indexed, served fast to strangers. The console is the portal where a customer builds and manages theirs, an authenticated app behind a login. Two surfaces, opposite jobs. The console is not a Next.js app, and it is not react-router or TanStack Router either. Routing there is a system we own outright, and there was never a bake-off. The reason is the closest thing this codebase has to a thesis:

There was no bake-off. By the time the console needed routing, the codebase already generated its API client, its cache keys, its hooks, and its mocks from one schema. The route table was one more config from the same machinery.

The hub decision

The architecture committed to its structure before this repository was two weeks old. The API is the hub, and everything that renders is a satellite. The API describes itself in an OpenAPI schema, and every consumer works from that description, the browser SPA that is the console, the Electron desktop shell, a native mobile client if one ever becomes necessary, the SDKs, and consumers that aren't browsers at all. Tool-calling agents landed in the repo earlier this month. None of them get a private contract. The schema is the contract.

Satellites are replaceable on purpose. Electron is a solid way to wrap an SPA into a desktop app, and I don't fully trust it as a long-term answer, because wrapper quality is somebody else's roadmap. If the day comes when only a real native app will do, the satellite gets swapped and the hub doesn't move. That is the position I want. The presentation layer stays decoupled from the schematic nature of the API, so that no rendering choice made this winter can hold the data model hostage in year three.

The hub pays in directions that have nothing to do with rendering, too. A self-describing API gives you a Swagger surface for free, generated client bindings for free, and one place where permission checks live against the schema instead of once per rendering framework.

Next fights this shape. Next is not a router. It is a commitment to a rendering server, and the server becomes the center of gravity. Routes live in its world, data fetching threads through its lifecycle, and the presentation layer grows roots into a machine you now operate. If the architecture says clients are dumb satellites of a schema, putting a server-centered framework under the console is architecture by accident.

What Next is actually for

Next earns its keep when server rendering is the product, for pages that must index, first paint for anonymous cold visits, markup assembled before the client wakes up. That is a precise description of the websites this platform hosts, the thing customers come here to make. They exist to be found and to load fast for strangers, so they render through Next. The console is the opposite case, behind a login, nothing to show a crawler, long warm sessions where a served HTML shell buys nothing.

Three weeks ago, on December 30th, both decisions landed eight hours apart.

Dec 30, morning

The homegrown router

Its cleanup pass lands (b01964564).

Dec 30, evening

Next.js arrives

An SSR frontend for public pages, ISR and 60-second revalidation, for SEO (e40a9c13b).

The part that made it nearly free

The objection to a homegrown router was never the runtime. Path matching is a solved problem the size of a util file. The objection is everything around it, keeping the route table, the view files, and the types in agreement, forever, by hand. That maintenance bill is what makes "just use the framework" the right default.

Here, the bill is already paid. The generated package runs ten generator scripts:

packages/generated — the generator scripts

"generate:api": "openapi-zod-client ${OPENAPI_SPEC_FILE:-http://127.0.0.1:5175/docs/json} -o src/client/api.ts","generate:keys": "tsx scripts/generate-keys.ts","generate:hooks": "tsx scripts/generate-hooks.ts","generate:client-routes": "tsx scripts/generate-client-routes.ts","generate:mocks": "tsx scripts/generate-mocks.ts","generate:factories": "tsx scripts/generate-factories.ts",

In the first line, the client's API bindings materialize from the running dev server's own OpenAPI spec. That 127.0.0.1:5175 URL is the API describing itself. The description becomes typed code. The same machinery emits the query cache keys, the data hooks, the form validation, the mocks and factories the tests run on, and the same one-declaration habit keeps the reactive layer honest. Routing is a customer of the generator, not the reason it exists.

In that context, file-based routing is a filesystem that is already a declaration.

A screenshot of a file directory tree in a code editor with folders and TypeScript files.
The routes/ tree in Zed. Every folder ends in a view.tsx, with no route config file in sight.

A directory means a path. A [bracketed] directory means a parameter. A view.tsx means a page. The generator walks the tree and emits the route table, and the build refuses to proceed without it. The fix commit that pinned this down says ensure routes are regenerated before typecheck and build. The tree is load-bearing, and the compiler is its enforcement.

The generated route table

// AUTO-GENERATED - DO NOT EDIT// Run "pnpm generate:client-routes" to regenerate
import { default as OrganizationOrganizationSlugProjectsProjectSlugBrandingBrandingIdPage }  from "@giantcontext/core/routes/organization/[organizationSlug]/projects/[projectSlug]/branding/[brandingId]/view";
export const routes: RouteType[] = [  {    path: "/organization/:organizationSlug/projects/:projectSlug/branding/:brandingId",    component: OrganizationOrganizationSlugProjectsProjectSlugBrandingBrandingIdPage,    params: ["brandingId"],  },];

That import identifier is a name only a generator would write, and that is the point. Nobody will ever type it. The filesystem types it. Generated code answers to one aesthetic. Correct, regenerable, never edited by hand.

What is left for the runtime? Match the path, mount the view, hand over the params. The whole "framework" is a small Router component and its utils, code you can hold in your head, which means code you can afford to own.

The bake-off that never happened

There is no comparison matrix where react-router and TanStack Router were weighed and found wanting. I never opened either one's docs with intent. That isn't a diligence failure. It's what an upstream decision looks like from downstream. A router library would replace the part that is nearly free here (runtime path matching) and leave the expensive part, the agreement between filesystem, table, and types, as a hand-maintained convention. That trade is backwards, and it stays backwards no matter which library is on the other side of it. There was nothing to bake off.

The decision rule

Take the framework when it owns a problem you actually have. Next owns server rendering. The hosted websites have that problem. Next renders them.

Roll your own only when three things are true at once. The machinery that makes it cheap already exists for other reasons. If you'd be building the generator just for routing, buy the library. The framework fights your architecture, not your preferences. A rendering server at the center of a system designed around a schema hub is a shape problem, not a taste problem. And the part you'd own is small enough to read in a sitting, because you will be reading it at 2 a.m. someday, and it will be yours either way.

All three are true here. We rolled our own Next because, by the time we needed one, most of it had already been generated.

Have questions?

If you're interested in my work or Giant Context, contact me!

Contact Jesse
Legal
Privacy Policy
Terms of Service
Cookie Policy

© 2026 Giant Context. All rights reserved.

We rolled our own Next