Skip to content
DeepForgeby svx

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.

10 min read
  • 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.

Modal versus page decision treeTransient surfaces that disappear when the user is done stay overlays: the command palette, the assistant, and the sync panel. Sustained surfaces that deserve a URL become routes: practice problems, learning paths, labs, and every other destination.Where should this UI live?Transient — gone when done?palette · assistant · sync statusSustained — worth a URL?content · practice · progressyesyesOverlay⌘K · assistant · sync panelRoute/problems/la-001 · /paths/… · /labsEscape closes the top layer; focus returnsshareable URL · back control · SSGOpening a problem always navigates to /problems/<id> — never an overlay.
Fig 1.The taxonomy as a decision tree. Transient UI stays in overlays; anything sustained becomes a route with a real URL.

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.

Where each surface lives
OptionChosenWhy
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 UIchosenThe 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:

First-load JavaScript before and after the splitGzip kilobytes per route: /about falls from 1490 to 183.2 and /problems from 1499 to 250.8 after the header data chain is broken. The home landing still ships the full problem bank at 1552.8.beforeafterstill unsplitmeasure-bundle.ts · gzip kB, first load1600120080040001490183.21499250.81552.8/about/problems/ (home)−88%−83%full bank · next target
Fig 2.First-load JS in gzip kilobytes, measured from the build with scripts/measure-bundle.ts. /about and /problems dropped 88% and 83%; the home landing still ships the full problem bank.

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.

Design tokensOne palette, one type scale, one radius, two motion bands. Colors render from the live CSS variables so the diagram flips with the theme.Colorcanvascanvas-cardcanvas-softcanvas-midbody-midinkaccentlight values flip via :root.lightType scaleForgetitle 30 / 600Forgesection 18 / 600Forge your MLbody 14 / 40012 min readmeta 12 / 400Inter · JetBrains Mono for idsShape & motionradius 8pxone corner for all150–250ms micro400–600ms revealtransform + opacity onlyreduced-motion snaps
Fig 3.The token sheet the routes are built from: one palette that flips with the theme, one type scale, one 8px radius, and two motion bands.

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

  1. src/data/problems/problem-meta.ts and scripts/generate-problem-meta.ts — the light problem index and the script that keeps it current.
  2. scripts/measure-bundle.ts — per-route first-load JS measurement from a real build.
  3. docs/DESIGN-SYSTEM.md and src/components/PageShell.tsx — the written contract and the shell that implements the layout half of it.
  4. docs/next-wave-plan.md — the remaining bundle work (NW-02) and the route count.