A script rmSync'd a source file I had never committed — no git blob, no recycle bin, no editor backup. Most .js.map files Next.js writes into .next/ carry a sourcesContent array holding the original, pre-transform text of each module they compiled, so a project that has been built once since the file existed still has that file on disk, inside the build output. I got 660 lines back byte-for-byte. The lesson underneath is the one that comes first: back up every file a destructive script can touch, not just the ones git can restore.
The symptom
My booking template ships eighteen industry presets in src/config/industries/, plus a script that trims the template down to one for buyers who only sell one thing. That script is scripts/use-industry.mjs, and its second half does what it says:
// booking-template/scripts/use-industry.mjs — lines 129-141
for (const id of removed) {
const rel = `src/config/industries/${id}.ts`;
const abs = join(ROOT, rel);
if (!existsSync(abs)) {
warn(`Already gone: ${rel}`);
continue;
}
if (dryRun) changes.push({ file: rel, note: "delete (dry-run: kept)" });
else {
rmSync(abs);
changes.push({ file: rel, note: "deleted" });
}
}removed is every industry except the one you keep, and the list is read off the directory at run time rather than hardcoded:
const ALL = readdirSync(PRESETS_DIR)
.filter((f) => f.endsWith(".ts"))
.map((f) => f.replace(/\.ts$/, ""))
.sort();Eighteen presets in, one kept, seventeen rmSync'd. I ran it without --dry-run to confirm the trimmed build was still green, on the assumption that git checkout . would put everything back afterwards.
It put back fifteen. src/config/industries/tattoo.ts and music.ts were written that week and had never been staged. An untracked file has no blob in the object database, so there is nothing for git checkout to restore and nothing for git fsck to find dangling — the file was never in git's world at any point. rmSync does not go through the recycle bin. Two files, 660 and 716 lines of hand-written service catalogues, staff, hours and copy, gone with no trace in any tool I would normally reach for.
The one place it still existed
.next is build output. It is line 22 of this project's .gitignore:
/.next/Which means it is the directory nobody thinks twice about deleting — stale build, weird hydration error, rm -rf .next and try again. It was also the only copy of my file left.
Next.js emits source maps beside the chunks it compiles. In this project after a production build, .next held 446 .map files: 440 under .next/server, 6 under .next/build. 297 of them carry a sourcesContent array, together covering 2,373 module entries.
A source map is JSON with two parallel arrays:
{
"sources": ["../../../src/config/industries/tattoo.ts", "..."],
"sourcesContent": ["import type { Industry } from \"@/lib/booking/types\";\n...", "..."],
"mappings": "AAAA,SAAS..."
}The important part is that sourcesContent[i] is not the compiled output and not something reconstructed from mappings. It is the exact text the compiler read for sources[i], comments and all. Source maps carry it so a debugger can show you your own file without needing the file. Which means a source map is, incidentally, a complete verbatim archive of every module in the build graph.
tattoo.ts was in three of them:
.next/server/chunks/src_config_site_ts_0u0r17p._.js.map
.next/server/chunks/ssr/src_config_site_ts_04fof7e._.js.map
.next/server/chunks/ssr/src_config_site_ts_17j6adi._.js.mapAll three held identical content, because all three compiled the same src/config/site.ts import graph into different chunk groups.
The recovery script
Walk .next, parse each .map, cross sources against sourcesContent, write the match:
#!/usr/bin/env node
// recover-from-sourcemap.mjs — usage: node recover-from-sourcemap.mjs <path-suffix> [outFile]
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const [suffix, outFile] = process.argv.slice(2);
function walk(dir) {
let out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out = out.concat(walk(p));
else if (e.name.endsWith(".map")) out.push(p);
}
return out;
}
const byContent = new Map(); // content -> [map files that carry it]
for (const file of walk(".next")) {
let map;
try { map = JSON.parse(readFileSync(file, "utf8")); } catch { continue; }
if (!Array.isArray(map.sources) || !Array.isArray(map.sourcesContent)) continue;
map.sources.forEach((src, i) => {
const content = map.sourcesContent[i];
if (typeof content !== "string" || !content) return;
// `sources` are relative ("../../../src/…") and percent-encode
// dynamic route segments: src/app/%5Blocale%5D/page.tsx
let norm = src.replace(/^(\.\.\/)+/, "");
try { norm = decodeURIComponent(norm); } catch {}
if (!norm.endsWith(suffix)) return;
if (!byContent.has(content)) byContent.set(content, []);
byContent.get(content).push(file);
});
}
if (byContent.size === 0) {
console.error(`No source map in .next carries a file ending in "${suffix}".`);
process.exit(1);
}
const versions = [...byContent.entries()].sort((a, b) => b[0].length - a[0].length);
console.log(`Found ${versions.length} distinct version(s) of "${suffix}":\n`);
versions.forEach(([content, maps], i) => {
console.log(` [${i}] ${Buffer.byteLength(content)} bytes, ${content.split("\n").length} lines, in ${maps.length} map(s)`);
console.log(` first line: ${content.split("\n")[0].slice(0, 72)}`);
});
if (outFile) {
writeFileSync(outFile, versions[0][0], "utf8");
console.log(`\nWrote version [0] to ${outFile}`);
}Running it:
$ node recover-from-sourcemap.mjs "industries/tattoo.ts" src/config/industries/tattoo.ts
Found 1 distinct version(s) of "industries/tattoo.ts":
[0] 26320 bytes, 660 lines, in 3 map(s)
first line: import type { Industry } from "@/lib/booking/types";
Wrote version [0] to src/config/industries/tattoo.tsdiff against the file I later rebuilt from memory: no output. Byte-identical, 26,320 bytes. music.ts came back the same way at 28,316 bytes and 716 lines.
Three details in there are load-bearing, and I got each of them wrong on the first attempt:
Group by content, not by map. A file usually appears in several chunks. If the build is stale in one runtime and fresh in another, those copies differ, and you want to see that rather than let the last write win. Keying the map on content collapses identical copies and surfaces genuine divergence as Found 2 distinct version(s).
Match by suffix, and decode first. sources paths are relative to the chunk, so the ../ depth varies — ../../../src/config/… in .next/server/chunks, ../../../../src/config/… one level deeper. And the App Router percent-encodes dynamic segments. In this build, 71 real src/ entries contain %5B and zero contain a literal [:
../../../../src/app/%5Blocale%5D/admin/layout.tsx
../../../../src/app/%5Blocale%5D/page.tsxSkip the decodeURIComponent and every route file in an App Router project silently fails to match.
Buffer.byteLength, not .length. The recovered string reports 25,919 characters and 26,320 bytes. Comparing a JS string length against a file size on disk will tell you the recovery is wrong when it is exact.
What is not in there
I checked the whole build against the tree: 374 of the project's 394 source files were recoverable. The 20 that were not fall into five groups, each for a mechanical reason.
Type-only modules (8). src/lib/payment/types.ts, src/lib/modules/types.ts and the other six types.ts files have zero runtime exports — grep for ^export (const|function|class|let|var|default) returns 0 on each. The transform erases them entirely, so they never become a module in any chunk and never get a map entry.
ssr: false components (1). src/components/preview/TweakPanel.tsx is only ever loaded like this:
// src/components/preview/TweakPanelMount.tsx — line 16
const TweakPanel = dynamic(() => import("./TweakPanel").then((m) => m.TweakPanel), {
ssr: false,
});It never enters the server graph, so it is absent from .next/server. And it is not in .next/static either, because browser source maps are opt-in: productionBrowserSourceMaps is not set in next.config.ts, and .next/static contains exactly 0 .map files. Every one of my 297 usable maps was server-side.
Files nothing imports (3). src/components/admin/AdminTimeInput.tsx and src/components/account/AccountSidebar.tsx appear in no other file. ModuleAction.tsx is mentioned once in the codebase — inside a doc comment in DownloadButton.tsx. Never imported means never compiled means never mapped.
Modules reached only through a dead export (6). This is the group I did not expect, and the one that would have sunk the recovery had the deleted file landed in it. src/lib/analytics/ga.ts, meta.ts and noop.ts are imported — at the top of src/lib/analytics/index.ts, which is itself in the maps. But the only thing anything imports from that barrel is ANALYTICS_EVENTS (src/components/admin/AnalyticsView.tsx); getAnalytics(), the function that touches the three providers, is never called. Tree-shaking drops it, the three imports go with it, and the string gaAnalytics appears in zero .js files under .next/server. Same shape for src/lib/docs-shared.ts, whose one runtime export (DOCS) has no consumer — both importers take only types — and for the two pure re-export barrels src/lib/availability/index.ts and src/lib/coupons/index.ts, which are absent while compute.ts and validate.ts behind them are present.
CSS (2). src/app/globals.css and src/styles/themes.css go through a different pipeline and produce no .js.map.
None of that is a limitation of the technique. It is the same rule stated five ways: the build archives what the build compiled — not what you wrote, and not what you imported.
How to catch it next time
The recovery worked, but it worked on luck — one stale .next I happened not to have cleaned. Two checks, both cheap.
Before any script that deletes, list what git cannot give back. Not git status, which buries untracked files among hundreds of modified ones. This:
git ls-files --others --exclude-standard src/config/industries/Untracked, not-ignored files in the path the script is about to touch. At the time it would have printed tattoo.ts and music.ts — the exact two I lost. Empty output means git checkout is a complete undo. Non-empty means copy the whole directory somewhere outside the repo first, and copy all of it, not the subset you think is at risk. Distinguishing "tracked" from "untracked" by memory across a tree with hundreds of dirty files is not something I can do, and it is the entire failure.
If a file is already gone, do not touch .next. Not rm -rf .next, not next build, not next dev — a rebuild overwrites the chunk that holds your file, and the fresh map records the module graph as it is now — with your file no longer in it. Copy the whole directory to a scratch location before running anything, then recover from the copy. .next is disposable right up until the moment it is the only artifact holding your work, and there is no warning when it crosses that line.
tattoo.ts and music.ts — the two files this pulled back — ship as industry presets in the Next.js booking and appointments template, together with the use-industry.mjs script that deleted them.
Verified on next@16.2.10 with Turbopack, Node 24.