← All articles

Five Production Pitfalls When Moving NestJS Fully to SWC for Speed

NestJS build times drop sharply with SWC, but test-green does not mean production-safe—five traps from a full test/serve/build SWC rollout with fixes and rationale.

Published
Five Production Pitfalls When Moving NestJS Fully to SWC for Speed cover image

Slow NestJS builds hit everyone once a project grows. A clean nest build can take tens of seconds, and nest start --watch rebuilds add a beat on every save—that gradually becomes stress.

Moving from tsc to SWC changes the feel completely. In my monorepo (api/ NestJS, app/ Ionic, TypeORM, production on AWS Elastic Beanstalk), nest build went from 38 seconds to 3.3 seconds. Watch rebuilds are instant.

The SWC switch itself is documented in the official recipe: set builder: "swc" in nest-cli.json and install @swc/core. That gets you "works for now." Between that and "stable in production" lie several pitfalls tutorials do not cover—tests all green but production fails to start, that kind of thing.

This article collects five traps I hit moving test, serve, and build fully to SWC and shipping to production. Less about the how-to, more about what will bite you next and why—hopefully useful mine-clearing for anyone SWC-ing NestJS.

Prerequisite: adoption is a few lines

The foundation follows the official recipe; I skim this part.

npm i -D @swc/core @swc/cli @swc/jest
// nest-cli.json
{
  "compilerOptions": {
    "deleteOutDir": true,
    "builder": "swc"
  }
}

Both nest build and nest start use SWC. Put .swcrc at the project root and the SWC builder picks it up automatically.

// .swcrc
{
  "jsc": {
    "parser": { "syntax": "typescript", "decorators": true },
    "transform": { "legacyDecorator": true, "decoratorMetadata": true },
    "target": "es2023",
    "keepClassNames": true
  },
  "module": { "type": "commonjs", "importInterop": "none" }
}

legacyDecorator and decoratorMetadata are required for NestJS DI (emitDecoratorMetadata-equivalent metadata). I also keep keepClassNames to protect metadata keyed on class names. Every article covers this far.

The trouble was the final importInterop and behavior the snippet does not show. I go through them in order.

Pitfall 1: Tests passing ≠ build passing (sources outside src)

The first trap after SWC: locally npm test and npm run start pass, but production build fails to start with Cannot find module.

The SWC builder does not emit sources outside nest-cli.json's sourceRoot (usually src). My project had TypeORM entities in schemes/ at the repo root, referenced from inside src as ../../../schemes/. tsc included them in dist; SWC treats outside src as out of scope, so they vanish at runtime.

The nasty part: Jest can still pass. @swc/jest transpiles files on demand, so it resolves even outside src. "Tests are green" is not a production guarantee.

The fix: move sources outside src under src/.

git mv schemes src/schemes

Then fix import paths. One discovery: because the destination is one level inside src, removing exactly one ../ from (../)+schemes/ imports fixes every importer uniformly. Old paths varied in depth but all pointed at repo-root schemes/—everyone's relative distance shrinks by one.

# Make every ../schemes/, ../../schemes/, ../../../schemes/, … one ../ shallower
node -e '
const fs=require("fs"),cp=require("child_process"),DIR="schemes";
const re=new RegExp("(?:\\.\\./)+"+DIR+"/","g");
const files=cp.execSync(`grep -rlE "(\\.\\./)+${DIR}/" src --include=*.ts`,{encoding:"utf8"}).trim().split("\n").filter(Boolean);
let n=0;for(const f of files){const s=fs.readFileSync(f,"utf8");const o=s.replace(re,m=>m.replace("../",""));if(o!==s){n+=(s.match(re)||[]).length;fs.writeFileSync(f,o);}}
console.log("files",files.length,"sites",n);
'

One exception: files directly under src (src/x.ts) referencing the moved folder need ../schemes, which is correct—removing one ../ wrongly becomes schemes. Find depth-zero importers with for f in src/*.ts; do grep -l "../schemes/" "$f"; done and fix by hand to ./schemes.

After rewrites, confirm zero errors with tsc --noEmit. All imports resolving proves the move landed completely.

Pitfall 2: interop must be opposite for build and jest

The last line in .swcrc, importInterop, took the most time to understand in this migration.

SWC's default interop wraps import * as x from 'cjs' into a namespace object you cannot call directly. Some CommonJS modules export a callable function directly, like compression(). With SWC default interop that becomes x is not a function.

Why tsc did not fail: my tsconfig has esModuleInterop: false, so tsc assigns require() results in a callable form. To align SWC with tsc, set importInterop: "none".

// .swcrc
"module": { "type": "commonjs", "importInterop": "none" }

Rule of thumb: match importInterop to tsconfig's esModuleInterop. With esModuleInterop: true, default importInterop (swc) is correct. Check which you have before deciding.

node -e "const t=require('./tsconfig.json').compilerOptions;console.log({esModuleInterop:t.esModuleInterop})"

Putting the same settings in @swc/jest kills all tests

The real trap: after fixing build with importInterop: "none", I added the same to @swc/jest to stay aligned—and every test failed at startup with globalSetup file must export a function.

Jest's runtime depends on standard interop. With importInterop: "none", Jest cannot read globalSetup as a module exporting a function; tests die before running one case.

So I do not put module.importInterop in Jest's transform. Leave default interop.

// Jest configuration (package.json, etc.)
"transform": {
  "^.+\\.(t|j)s$": ["@swc/jest", {
    "jsc": {
      "parser": { "syntax": "typescript", "decorators": true },
      "transform": { "legacyDecorator": true, "decoratorMetadata": true },
      "target": "es2023"
    }
  }]
}

Same SWC, opposite settings: build importInterop: "none", jest default. That works because .swcrc is not read by @swc/jest—build and jest SWC configs are fully independent. Trying to unify "because it's SWC" breaks one side every time.

Pitfall 3: dist layout flips (do not set rootDir)

Moving outside-src sources under src changes the build common root to src. Output structure changes: what was dist/src/main.js flattens to dist/main.js.

That quietly breaks production entry paths: package.json's start:prod (node dist/src/main), serverless handler: dist/src/..., Sentry sourcemap upload paths—anything pointing at dist/src/... misaligns and production deploy or start breaks. I inspect local dist and fix to match actual output.

npm run build && ls dist   # Adjust this to the actual output structure

Fix every yml/json/Procfile/Dockerfile containing dist/src. Paths buried in CI workflows separate from the app build—sourcemap upload, for example—are easy to miss; grep -rn "dist/src" across the repo is safer.

You want rootDir: "./src"—but adding it breaks things

You might think rootDir: "./src" in tsconfig fixes output structure. For me it backfired.

From @nestjs/cli 11.0.19, setting rootDir: "./src" regresses and brings back dist/src/ (nestjs/nest#16785). SWC builder output layout swings between dist/main.js and dist/src/main.js depending on CLI version and rootDir. Do not set rootDir. A comment in tsconfig saying "do not add rootDir" prevents future-me or teammates from "helpfully" adding it.

Then guard entry existence in postbuild so deploy notices layout changes.

// package.json
"postbuild": "for f in dist/main.js; do test -f \"$f\" || { echo \"ERROR: $f missing — swc/dist layout changed (do NOT set rootDir; see nestjs/nest#16785)\"; exit 1; }; done"

Assume output structure may change with version, and fail CI when it does.

Pitfall 4: --type-check does not stop the build

SWC does not type-check. It transpiles fast; type verification needs another path.

NestJS has nest build --type-check / nest start --type-check. I expected type errors to fail the build—but --type-check succeeds the build even with type errors (nestjs/nest-cli#2646, by design). Errors log only; you still reach "successfully started".

So --type-check is not a CI gate if I want type errors to fail. I add plain tsc --noEmit separately.

// package.json
"typecheck": "tsc --noEmit -p tsconfig.build.json"

In production-build CI (deploy workflows, etc.), run npm run typecheck before build. --type-check on dev serve is still useful to surface type errors in the terminal—different roles, use both.

// package.json
"start:dev": "nest start --watch --type-check"

If lint CI's build step only needs "does it type-check?" without dist, replacing nest build (with emit) by tsc --noEmit skips emit and gets faster—about 38 seconds to about 10 seconds for me.

Pitfall 5: node dist/main alone is risky verification

Last, how to verify. After SWC production build, seeing Nest application successfully started from node dist/main is tempting—but not enough.

NestJS projects often have auxiliary entries besides the main HTTP server—create-sitemap, migration scripts, and similar. These use NestFactory.createApplicationContext(SubsetModule) to boot a partial module graph, not AppModule.

Trap: @Global modules (Realtime, for example) are not registered unless the SubsetModule for that context imports them explicitly. Dependencies resolved from AppModule fail in the auxiliary partial graph with UnknownDependenciesException.

This is not always SWC-specific. If types resolve, it is often a pre-existing module graph mistake that surfaced the first time production ran the auxiliary entry. SWC migration is a good moment to exercise every dist entry and fix them.

For verification, hit every dist entry at startup level, including those listed in the postbuild guard.

  • <x> is not a function → revisit pitfall 2 interop
  • UnknownDependenciesException → add missing modules (often @Global) to that context's SubsetModule imports

For tests, capture a baseline pass/fail count with tsc before migration and match the same after SWC—zero regression. Some failures without production-equivalent auth locally are expected; include that count in the baseline so comparison does not drift.

Summary

Five pitfalls between "works for now" and "stable in production" when moving NestJS fully to SWC:

  • Pitfall 1: SWC does not emit sources outside src. Move them under src/ and remove one ../ from imports. Green tests do not guarantee build output
  • Pitfall 2: interop is opposite for build (importInterop: "none") and jest (default). .swcrc is not read by jest—they are independent
  • Pitfall 3: consolidating under src flattens dist/srcdist. Do not set rootDir (setting it regresses). Use postbuild guard for early detection
  • Pitfall 4: nest build --type-check does not fail on type errors. Put tsc --noEmit in a separate gate
  • Pitfall 5: node dist/main alone is risky. Auxiliary entries' partial graphs may miss @Global modules. Exercise every dist entry

SWC's speed payoff is big, but type-checking disappears and subtle differences from tsc bite in production. Cover these five upfront and most "tests green, production only fails" cases should be avoidable. I hope this helps others on the same path.

See you next time.