ESM & TypeScript Strategy
ESM-first module architecture with TypeScript v5.9.3, dual-mode exports, subpath exports, ts-reset, and tsgo (TypeScript Go compiler) for fast builds and typechecks.
Overview
This document describes the monorepo’s ESM-first strategy and how packages are authored and consumed across different runtimes (Next.js and Node/Fastify).
Key ideas:
- ESM everywhere: packages are
"type": "module"and useimport/export - Source in monorepo, dist for publishing: workspace development imports TypeScript source; publishing switches to
dist/ - Subpath exports: consumers import from explicit subpaths (no root barrels)
- Runtime-aware configs: Next.js and Node/Fastify have different constraints
- Fast compilation/typecheck: we use tsgo (TypeScript Go compiler) for builds and
--noEmittypechecks
ESM-First Architecture
All packages use "type": "module" in package.json. This enables:
- Native ESM imports (
import/export) throughout the codebase - No CommonJS interop overhead
- Better tree-shaking and bundling optimization
Dual-Mode Package Exports
Packages use a dual-mode export strategy for optimal developer experience and publishing:
Development Mode (Monorepo)
Packages expose TypeScript source for workspace consumption. Two patterns exist:
Pattern A – import points to source (packages like @repo/core, @repo/react):
{
"type": "module",
"exports": {
".": { "types": "./src/index.ts", "import": "./src/index.ts" }
}
}Pattern B – source condition for bundlers, node/import for Node (packages like @repo/utils, @repo/sentry, @repo/email, @repo/notif):
{
"exports": {
"./async": {
"types": "./src/async/index.ts",
"source": "./src/async/index.ts",
"import": "./dist/async/index.js",
"node": "./dist/async/index.js",
"default": "./dist/async/index.js"
}
}
}- Next.js (webpack): resolves
sourcefirst to consume TypeScript directly. - Node / serverless (Vercel, etc.): resolves
nodeorimportto compileddist/. The explicitnodeanddefaultconditions ensure serverless runtimes always get the compiled output, avoidingERR_MODULE_NOT_FOUNDwhen resolvers prefer non-standard conditions.
Publishing Mode (npm)
The prepack script transforms exports to compiled dist/ before publishing. Subpath exports are preserved.
{
"exports": {
"./async": {
"types": "./dist/async/index.d.ts",
"import": "./dist/async/index.js"
}
},
"files": ["dist"]
}Process (high level):
prepackruns beforepnpm pack/publish- Package is built (ESM +
.d.ts) prepare-publish.mjsswitches all export paths fromsrc/→dist/postpackrestores the original exports after packing
Directory/Subpath Exports
Packages use subpath exports for better tree-shaking and explicit imports:
Pattern:
{
"exports": {
"./async": { "types": "./src/async/index.ts", "import": "./src/async/index.ts" },
"./web3": { "types": "./src/web3/index.ts", "import": "./src/web3/index.ts" },
"./components/*": { "types": "./src/components/*.tsx", "import": "./src/components/*.tsx" }
}
}Rules:
- ✅ Always use subpath imports:
import { delay } from "@repo/utils/async" - ❌ Never use root imports:
import { delay } from "@repo/utils"(no root export) - ✅ Use directory patterns for UI:
import { Button } from "@repo/ui/components/button"
Runtime consumption
Next.js apps (App Router)
Next.js apps import TypeScript source directly using package interpolation:
Configuration:
// next.config.mjs
export default {
transpilePackages: ['@repo/ui', '@repo/core', '@repo/react', '@repo/sentry', '@repo/utils'],
serverExternalPackages: ['import-in-the-middle', 'require-in-the-middle'],
webpack: config => {
// Resolve "source" condition so Next.js uses TypeScript source from workspace packages
config.resolve.conditionNames = ['source', ...(config.resolve.conditionNames ?? ['...'])]
// Allow .js imports to resolve to .ts/.tsx in transpiled packages
// Merge with existing extensionAlias if present to preserve Next.js defaults
const existingExtensionAlias = config.resolve.extensionAlias || {}
config.resolve.extensionAlias = {
...existingExtensionAlias,
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.jsx': ['.tsx', '.jsx'],
}
return config
},
}// tsconfig.json
{
"compilerOptions": {
"paths": {
"@repo/ui/*": ["../../packages/ui/src/*"],
"@repo/core": ["../../packages/core/src/index.ts"],
"@repo/core/*": ["../../packages/core/src/*"]
}
}
}How It Works:
conditionNames: ['source', ...]makes Next.js prefer thesourceexport when packages define itextensionAliaslets.jsimports resolve to.ts/.tsxin transpiled packages- TypeScript path mappings resolve
@repo/*tosrc/for packages that need them transpilePackagesensures workspace packages are transpiled during build- No pre-build step required – Fast refresh works with direct TypeScript source
Rules:
- ✅ Add all workspace packages to
transpilePackagesarray - ✅ Use TypeScript path mappings for direct
src/imports - ✅ Import from package names (not relative paths):
import { Button } from "@repo/ui/components/button" - ❌ Don't use relative paths across packages:
import { Button } from "../../packages/ui/src/components/button"
Node.js/Fastify apps
Node.js apps use native ESM and typically run TypeScript in development via tsx (Node ESM loader), while builds/typechecks use tsgo.
Development:
{
"scripts": {
"dev": "node --watch --import tsx server.ts"
}
}Build / watch / typecheck (tsgo):
{
"scripts": {
"build:ts": "tsgo",
"watch:ts": "tsgo -w",
"checktypes": "tsgo --noEmit"
}
}What is tsgo?
tsgo is the TypeScript Go compiler (preview tool as of 2025). We use it because it’s fast and works well as the default tool for monorepo compilation and --noEmit typechecks. Known limitations: incomplete declaration (.d.ts) emit, gaps with some downlevel emit targets, watch-mode caveats (watch:ts), and incompatibility with the legacy TypeScript compiler API. The scripts build:ts, watch:ts, and checktypes are affected. Contributors should avoid relying on unsupported emit modes or legacy-API tooling integrations until these gaps are resolved.
TypeScript config (Node ESM):
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}Rules:
- ✅ Use
tsx(or equivalent) for dev execution when you need to run TS directly - ✅ Use
tsgofor builds and--noEmittypechecks (default across apps/packages) - ✅ Configure
moduleResolution: "NodeNext"for proper ESM support - ✅ Import workspace packages normally – dev resolves to
src/(tsx); built output resolves todist/(Pattern B packages includenode/defaultconditions for serverless)
Import Extension Rules
Extensionless imports (import "./utils/delay") work only when using moduleResolution: "bundler" (e.g., Next.js) or tooling that supports extensionless resolution. With moduleResolution: "NodeNext" (used for Fastify/Node ESM), Node requires explicit .js extensions for relative imports.
In TypeScript source:
- ✅
import { delay } from "./utils/delay.js"(required for NodeNext/strict ESM) - ✅
import { delay } from "./utils/delay"(bundler environments only—Next.js, etc.) - ❌
import { delay } from "./utils/delay.ts"(don't use.tsextension)
Best Practice:
- Use
.jsextensions for NodeNext/strict ESM (Fastify, Node apps) - Omit extensions only in bundler-based setups (
moduleResolution: "bundler") - Never use
.tsextensions in imports
TypeScript Strategy
The monorepo uses TypeScript v5.9.3 with strict mode enabled and enhanced type safety via @total-typescript/ts-reset.
Shared TypeScript Configuration
Packages extend shared TypeScript configs (strict defaults + runtime-specific overrides).
Base Configuration (base.json):
- Strict mode enabled
- ES2022 target
- ESNext modules
- Bundler module resolution (for Next.js/bundler environments)
noUncheckedIndexedAccessenabled (safer array/object access)ts-resetintegration for enhanced type safety
Next.js Configuration (nextjs.json):
- Extends base config
- Next.js plugin support
- Bundler module resolution
- JSX preserve mode
- No emit (Next.js handles compilation)
React Library Configuration (react-library.json):
- Extends base config
- React JSX support
- Declaration files for library distribution
ts-reset Integration
@total-typescript/ts-reset is configured globally to enhance TypeScript's built-in type safety:
Key effects:
- Some “unsafe by default” APIs become
unknownuntil validated - TypeScript nudges you toward boundary validation (e.g. Zod in app code, TypeBox in Fastify)
Module resolution
Bundler Resolution (Next.js, React libraries):
moduleResolution: "bundler"- Optimized for bundlers- Works with ESM imports and subpath exports
- TypeScript resolves to source files via path mappings
Node Resolution (Fastify, Node.js apps):
moduleResolution: "NodeNext"- Native ESM support- May require explicit file extensions in strict ESM contexts
- Works with a TypeScript runtime (
tsx) in development
Rules:
- ✅ Use
bundlerresolution for Next.js apps and React libraries - ✅ Use
NodeNextresolution for Node.js/Fastify apps - ✅ Exceptions allowed for infrastructure tooling (Pulumi, Anchor/Solana)
Related Documentation
- Package Conventions - Package organization and naming
- Packages Reference - Complete package API reference
- Monorepo Structure - Overall monorepo architecture