Deleting 42,000 Lines

knip found 57% of the files I eventually deleted. The other 43% lived behind retired routes that still looked alive to the module graph.

Frontend Engineer
Product Engineering
Jun–Sep 2026
Old product routes kept entire component trees alive after their replacements shipped. A clean unused-file report could still leave a retired feature in the build.
Combine knip with route reachability from known live entry points, then verify deletion candidates against real consumers and product context.
422 files removed, 31 fewer page files, and measured local improvements of about 13% in build time and 17% in cold type-check time.
A retired route and its private modules fade away while the live branch stays connected

The Other 43%

A production dashboard had accumulated several generations of the same product. Major screens had live replacements. Other pages had become tabs elsewhere. The old routes stayed in pages/, along with their components, hooks, services, and types. Internal names and paths in this account are generalized.

The cleanup began with knip and grew into a route audit. Claude helped with the analysis and implementation; my work was to define what counted as live, challenge the deletion list, and keep uncertain product behavior out of scope.

240
182
422
31

Those counts came from running the final knip configuration against the pre-cleanup commit and intersecting its findings with the final deletions. All 240 flagged files were removed. The remaining 182 were 31 page files and 151 modules or assets used only by those pages.

A retired page can be the root of a perfectly valid module graph. Nothing below it looks unused until the root itself is questioned.

Give the Tool a Boundary

The first pass used knip to find unreferenced files and unused packages. In this Pages Router setup, every page was an entry point. That made knip useful for disconnected modules, but unable to decide whether a route still belonged in the product.

  • Protect implicit dependencies: Keep @svgr/webpack, which the webpack configuration uses to load SVGs. A package can be required by the build without appearing in an ordinary source import.
  • Keep tests without a runner: The repo had test files but no configured test runner. Excluding them from deletion preserved behavioral documentation that otherwise appeared unreachable.
  • Separate findings from permission to delete: Restrict automatic fixes to files and dependencies. Export and type removal stayed outside that pass because a public module surface needs its own review.

The branch also aged between June and late August. After rebasing, a messaging dependency briefly looked necessary because its usage had been checked on the main branch. Its only consumer was already deleted in the cleanup. From then on, dependency checks had to describe the tree that would actually ship.

Follow the Live Routes

“Not in the sidebar” was too weak a deletion rule. Detail pages, OAuth callbacks, bookmarked URLs, and permission-gated screens can all be live without a navigation item. I narrowed the target to older routes with shipped replacements, then traced navigation from known live entry points.

Both pages look alive.

Each page is an entry point with valid imports. Starting from every page keeps both branches, even when the product has retired one of them.

Simplified pseudocode for the analysis. Known entry points include sidebar routes, external links, callbacks, and framework pages. Pattern matching generates review candidates; it cannot prove the absence of external traffic or tenant-specific usage.

const kept = new Set(knownEntryPoints);
let previousSize;

do {
  previousSize = kept.size;
  const modules = walkImports(kept);

  for (const module of modules) {
    for (const target of resolveNavigation(module)) {
      kept.add(target);
    }
  }
} while (kept.size !== previousSize);

const candidates = allPages.filter(page => !kept.has(page));
// Review replacements, external entry points, and shared
// consumers before approving any candidate for deletion.

The analysis started with sidebar routes, known external entry points, callbacks, and framework pages. It walked their imports, inspected navigation in those modules, and added newly reached routes. Repeating until the set stopped growing avoided a subtle trap: two abandoned pages can link to each other without either being reachable from the live product.

The result was a review list, not proof that a URL had no users. External links and server-driven configuration still needed explicit consideration.

Teach the Analysis to Doubt

Manual checks repeatedly exposed assumptions in the analyzer. A route-constant table made every page look live. An unresolved template prefix made an active detail page look dead. Each correction tightened what qualified as evidence.

  1. Definitions are not navigation

    A ROUTES map and sidebar constants could resolve a destination, but their declarations could not count as visits to it. Actual router.push, router.replace, href, pathname, and redirect destinations mattered. Commented-out calls did not.

  2. Resolve only the unknown segments

    For a path such as /preferences/${tab.slug}, keep /preferences/ anchored and wildcard the interpolation. Replacing the whole template with a wildcard made unrelated routes look reachable. Dynamic route prefixes also needed mapping back to their constant keys.

  3. Check whether an import is consumed

    Two live pages imported a LegacyHeader component but never rendered it. Those stale bindings did not justify keeping the component. Side-effect imports remained a separate case, and root-absolute asset imports needed to be resolved too.

The Code That Stayed

Deleting a legacy management directory produced eight TypeScript errors. Its page was retired, but three files containing constants, API calls, and types still supported an active resource-creation flow. Restoring those shared files fixed the boundary: remove the retired screen, retain the services another feature owns a dependency on.

  • A preferences page with a real permission check: It resembled a redirect stub, but rendered a 404 according to tenant configuration and permissions. Moving that behavior into a static redirect would have bypassed the page's gate, so it stayed.
  • Enterprise login behind a tenant flag: The frontend could show how a server-driven flag controlled the entry point. It could not establish whether any tenant still enabled it. Removal stopped pending a backend configuration check; context shared with an active integration stayed too.
  • Recent and unresolved routes: New product areas, internal reference pages, OAuth callbacks, and routes whose usage could not be confirmed stayed in place. Absence of evidence was not enough to expand the delete list.

Old Does Not Mean Unused

One tempting shortcut was to use lint violations as a signal of age. Single-quote rules had been introduced long before, and staged-file linting only updated files people touched. Double quotes could reveal code left behind by the product.

416
54
38

Checking those files against reachability showed that the signal mostly identified stable code, including onboarding, notifications, and an OAuth callback. I rejected it as a deletion rule and left formatting for a separate change.

Close the Loose Ends

The deletions exposed smaller cleanup opportunities. Five obsolete dependencies went away. Two packages imported directly by live code were declared at their already-resolved versions instead of depending on transitive hoisting.

Three pure redirect pages moved into next.config.js. Their old URLs still had destinations through permanent redirects. That protection applied to those three paths; removing a page did not automatically preserve every retired URL.

Once the legacy pages were gone, one old button primitive had no consumers and another had only one. Migrating that final page-header action allowed both primitives to be deleted, leaving the current button component with 233 consumers. The component documentation also needed updating so future work would not recreate the retired API.

Measure the Right Win

83 → 72s
20.5 → 16.9s
108 → 77
−12%

The comparison used two worktrees, immediately before and after the merged cleanup, with the same node_modules directory symlinked into both. Two sequential clean builds per tree, with .next removed between runs, measured 88s / 78s before and 72s / 72s after. These are local measurements from a small sample, not a CI performance guarantee.

Tracked .ts/.tsx lines fell from 347,601 to 305,964, including comments and blank lines. The final squash diff changed 438 files with 2,263 insertions and 42,721 deletions. That diff includes lockfiles and edits to surviving files; 422 is the number of files deleted outright.

Shared First Load JS moved from 1.57 MB to 1.53 MB, about 2.5%. Route-level code splitting already kept much of the retired code out of unrelated page loads. The strongest measured wins were in builds, type checks, and the amount of source left to maintain.

The Finish Line

Verification combined whole-repo type checking, a production build, another knip pass, changed-file formatting and lint checks, and manual reference checks for deletions. The final configured knip run reported zero unused files and zero unused dependencies. Other finding categories still had outstanding items.

The handoff recorded what remained: the migrated button still needed visual QA in its error and success states; enterprise login needed tenant-usage confirmation; one directly imported dependency was still undeclared; and export, enum, and lint findings belonged in separate changes.

The lasting lesson was about choosing the right roots. A module graph answers what a page imports. It does not answer whether the product still needs that page. Making that distinction let me remove a large amount of code while being explicit about the behavior that still needed protection.