From one long page to 24 routes
DeepForge started as a single scrolling page with section state and modals stacked on top. It now ships 24 top-level destinations, 5,050 static problem pages, and 28 learning-path pages — and the routes that stopped importing the whole problem bank got 83 to 88 percent smaller on first load. This is the routing taxonomy, the shell contract, and the measured numbers.
- next.js
- performance
- design-system
The first version of DeepForge had one route. Everything — problems, paths, contests, the leaderboard, the assistant — was a section of src/app/page.tsx switched by view state, with a modal layer for anything that needed to feel focused. It was fast to build and it worked, until the product grew into 5,050 problems across 15 categories and the browser back button started feeling like a promise we could not keep.
1.The one-page era
With a single page, nothing was linkable except by hash, nothing was indexable except the landing, and every interaction had to be encoded in component state. Opening a problem meant stacking an overlay over the list you were already on; closing it returned you to a scroll position that might or might not survive. The modal architecture was genuinely good at one thing — keeping transient UI in place — and genuinely bad at everything that wants a URL: sharing a path, sending someone a problem, restoring a reading position, or rendering a page that search engines can index.
The migration landed in two commits: section modals became a categorized hub of real destinations, then the problem workspace overlay was retired so opening a problem always navigates to /problems/<id>. Today the app serves 24 top-level destinations — the home landing plus 23 entries in src/lib/sections.ts — and dynamic pages for 5,050 problems, 28 paths, and 15 categories, all statically generated.
2.The taxonomy: transient versus sustained
The rule we settled on is a single question: will the user come back to this, share it, or expect the back button to work? If the answer is yes, it is a route. If it vanishes the moment the task is done — the command palette, the assistant, the sync panel, a confirmation — it is an overlay.
Two supporting rules keep the split from drifting. Detail pages start with a labelled back control — ← All problems, ← Engineering — while ✕ is reserved for true modals. And opening a problem is never an overlay, not from a list, not from a chip, not from search: components announce a problem through the deepforge:open-problem event and the shell routes to the page. One code path for the most important interaction in the product.
| Option | Chosen | Why |
|---|---|---|
| Open a problem in an overlay | — | Back, sharing, and indexing all break. The workspace became a real page at /problems/<id> with saved code and progress read locally. |
| Keep section state in one hub page | — | A hub can only ever be one URL. Twenty-three destinations became 23 routes with their own titles, descriptions, and canonicals. |
| Overlay only the transient UI | chosen | The palette, assistant, and sync panel never need a URL, and they benefit from staying in place. Escape closes the top layer and focus returns to the trigger. |
3.The shell contract
Twenty-four pages stay consistent because they share one shell and one registry. Every route renders PageShell, which owns the header with the live solved count, the footer, the command palette, the skip link, and the problem-opening event. A page passes its title and blurb from SECTIONS_BY_ID, which guarantees exactly one <h1> per route and means the page title, the navigation label, and the metadata description come from the same string.
The shell is also a layout contract: a page container of mx-auto w-full max-w-6xl px-4 sm:px-6, whole-page rhythm of py-10 sm:py-14, and a narrow prose column of max-w-3xl nested inside the container rather than a second container of its own. That one rule is why an index page, a detail page, and this post all start at the same edge.
4.The data seam: stop shipping 5 MB
Routes solved the navigation problem but created a bundle problem. The problem dataset is a 6.3 MB source directory, and many client components imported the full objects just to render a title and a difficulty badge. Worse, the header pulled in the sync panel, which pulled in the auth and remote modules, which pulled the dataset into every route through a chain nobody intended. The measured result: the raw data chunk alone was 5,224.5 kB — 1,271.1 kB gzipped — on pages that never opened a problem.
Two seams fixed it. src/data/problems/problem-meta.ts exposes a light index — id, title, category, difficulty — and list, stat, and counter consumers import that instead of the full bank.[1] And the header data chain was broken by making auth and the remote sync engine lazy, so routes that do not use them no longer load them. Problem pages still load the full object, but only when you open a problem.
5.The measured wins
scripts/measure-bundle.ts parses every prerendered route in the build, collects the chunks each page actually loads, and reports raw and gzip sizes. Before the split, /about loaded 1,490 kB of gzipped JavaScript and /problems loaded 1,499 kB. After:
The honest part of the chart is the third bar. The home landing still weighs 1,552.8 kB gzip because it needs live counters over the full bank, and /stats, /badges, /playlists, and /certificates are still in the same range. Splitting the remaining consumers is the next performance task in docs/next-wave-plan.md, and the goal is boring: keep the data behind a lazy boundary so a route pays for it only when it opens something.
6.Keeping 24 pages consistent
A routing migration is also a design migration: twenty-four pages is more than anyone can hold in their head, so consistency has to be written down and enforced by structure. docs/DESIGN-SYSTEM.md is the single source for tokens, the type scale, the rhythm, the interaction patterns, and the motion rules. The shell implements the layout half; the document covers the rest.
Motion is part of the contract, not a coat of paint. Cards fade and rise once through Reveal, numbers count up once through CountUp, and ambient animation pauses off-screen and snaps off under prefers-reduced-motion. Only transform and opacity move. A page that follows the checklist renders identically without JavaScript, which matters when most routes are static HTML.
7.Honest holdouts
One destination still opens a reader as an overlay: the interactive articles in src/components/Articles.tsx. The modal treatment predates the routes migration, and by the taxonomy it is wrong — an article is exactly the thing you want to share and return to. Converting it to /articles/<slug> is the next pass, and until then it is the one place the old pattern survives.
The other holdouts are budgets. Detail pages still pay for the full problem object — correctly, but shared code could split further. And the sitemap advertises 5,050 problem URLs, 28 path URLs, and 15 category URLs; static generation is the thing to watch as the bank grows.
The version of DeepForge that exists today is the direct result of taking navigation seriously: every destination is a URL, every URL is a page, and every page loads only what it needs. Browse the problems and the taxonomy is visible in the address bar.
Code and references
src/data/problems/problem-meta.tsandscripts/generate-problem-meta.ts— the light problem index and the script that keeps it current.scripts/measure-bundle.ts— per-route first-load JS measurement from a real build.docs/DESIGN-SYSTEM.mdandsrc/components/PageShell.tsx— the written contract and the shell that implements the layout half of it.docs/next-wave-plan.md— the remaining bundle work (NW-02) and the route count.