πŸš€ Executive Summary

TL;DR: This guide addresses the common TypeScript module conflict (CommonJS vs. ES Modules) encountered in full-stack online game development, where client and server environments have differing module requirements. It provides three battle-tested solutions, ranging from quick bundler-based fixes to architecturally sound dual `tsconfig` setups and monorepo strategies, to help developers resolve these issues and efficiently build their games.

🎯 Key Takeaways

  • The core problem stems from Node.js (server) traditionally using CommonJS and browsers (client) standardizing on ES Modules, creating conflicts when a single TypeScript configuration attempts to serve both.
  • Solution 1, ‘Single Config + Bundler’, uses a bundler like Vite or Webpack to transform client code, allowing the source to primarily use CommonJS for Node.js compatibility.
  • Solution 2, ‘Dual `tsconfig` Files’, is preferred for serious projects, providing separate `tsconfig.server.json` (e.g., `module: “NodeNext”`) and `tsconfig.client.json` (e.g., `module: “ESNext”`) to cleanly separate build concerns.
  • Solution 3, the ‘Monorepo’ approach with tools like Turborepo or Nx, is for large-scale applications, treating client, server, and shared libraries as independent packages, each with its own tailored `tsconfig.json`.
  • The choice of solution depends on project scale: ‘Single Config + Bundler’ for prototypes, ‘Dual `tsconfig` Files’ for most serious full-stack projects, and ‘Monorepo’ for large, multi-service architectures.

TypeScript Online Game Template

Tired of wrestling with TypeScript’s module settings for your game’s client and server? This guide demystifies the CommonJS vs. ESM conflict and provides three clear, battle-tested solutions to get you building again.

So, You’ve Hit the TypeScript ‘module’ Wall in Your Game Dev Project. Let’s Talk.

I remember it clear as day. 2 AM, a critical hotfix deployment, and the CI/CD pipeline is glowing green. Everything looks perfect. We push the button to deploy to the `prod-game-cluster-alpha`, and… nothing. The Node.js service fails to start. I SSH into the box, check the logs, and see that one beautiful, infuriating error: Error [ERR_REQUIRE_ESM]: require() of ES Module not supported. A junior dev had updated a shared utility library to use a new package that was ESM-only, and our `tsconfig.json` set to `module: “CommonJS”` for the server just completely fell apart. We spent the next hour untangling a problem that, frankly, shouldn’t be this hard. This whole CommonJS vs. ES Modules thing in a full-stack TypeScript project feels like a rite of passage, and I see devs hit this wall all the time.

The “Why”: Two Worlds Colliding

Let’s get this straight first. The problem isn’t you, it’s history. For years, Node.js used the CommonJS module system (think require() and module.exports). It’s stable, it’s everywhere. The browser world, meanwhile, standardized on ES Modules (ESM), using the slick import and export syntax. TypeScript, with its tsconfig.json file, sits in the middle and asks you to choose a side with the "module" property.

For an online game, you have two different targets:

  • The Server: A Node.js environment.
  • The Client: A web browser.

They have different module requirements, and trying to use one single `tsconfig.json` for both is the root of all this pain. The server might need CommonJS for legacy compatibility, while the client code is best bundled as ESM. So how do we make them coexist peacefully?

The Fixes: From Duct Tape to a New Foundation

I’ve seen this problem solved a few ways in my career. Here are the three main paths, from the quick-and-dirty to the architecturally sound.

Solution 1: The Quick Fix – Let a Bundler Do the Dirty Work

This is the most common approach for a reason: it’s pragmatic and it works. The strategy is to pick one module system for your source code (usually CommonJS for its Node.js compatibility) and then use a bundler like Vite or Webpack to transform your client code into whatever the browser needs.

Your main `tsconfig.json` will look something like this, favoring the server:

{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "es2020",
    "moduleResolution": "node",
    "outDir": "./dist",
    /* ... other options */
  }
}

You write all your codeβ€”client, server, sharedβ€”using this config. For the server, you just run `tsc` and it spits out `.js` files in your `dist` folder that Node can run. For the client, you point Webpack/Vite at your client entry point (e.g., `src/client/index.ts`), and it handles the magic of bundling all those `require()` statements into a single, browser-compatible `.js` file.

Pro Tip: This is a great way to get a project off the ground quickly. It’s not “pure,” but who cares? Shipping code is what matters. The downside is that your client-side tooling is now doing some heavy lifting to paper over the module differences.

Solution 2: The Permanent Fix – The Dual Config Setup

This is my preferred method for any serious project. Here, we acknowledge that the client and server are different build targets and we give them each their own configuration. This provides clarity and avoids weird bundling artifacts.

Your folder structure might look like this:

/project
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ client/
β”‚   β”œβ”€β”€ server/
β”‚   └── shared/
β”œβ”€β”€ tsconfig.client.json
β”œβ”€β”€ tsconfig.server.json
└── package.json

tsconfig.server.json (for Node):

{
  "extends": "./tsconfig.base.json", // Optional base config
  "compilerOptions": {
    "module": "NodeNext", // Modern Node.js loves this
    "moduleResolution": "NodeNext",
    "outDir": "./dist/server"
  },
  "include": ["src/server/**/*", "src/shared/**/*"]
}

tsconfig.client.json (for the browser/bundler):

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "ESNext", // Let the bundler handle the final output
    "moduleResolution": "node",
    "outDir": "./dist/client" // Usually the bundler handles output anyway
  },
  "include": ["src/client/**/*", "src/shared/**/*"]
}

You then update your `package.json` scripts to run the correct build for each part:

"scripts": {
  "build:server": "tsc -p tsconfig.server.json",
  "build:client": "vite build", // Vite/Webpack will use tsconfig.client.json
  "build": "npm run build:server && npm run build:client"
}

This approach cleanly separates the concerns. The server code is compiled directly for Node, and the client code is prepared for the bundler. No more conflicts.

Solution 3: The ‘Nuclear’ Option – The Monorepo

If your project is growing, or if you plan to have multiple services, a shared library, and a client, it’s time to bring out the big guns: a monorepo. Using a tool like Turborepo or Nx, you treat each part of your application as a separate, independent package within one repository.

The structure would be more formal:

/game-monorepo
β”œβ”€β”€ apps/
β”‚   β”œβ”€β”€ client/  (its own package.json, tsconfig.json)
β”‚   └── server/  (its own package.json, tsconfig.json)
β”œβ”€β”€ packages/
β”‚   └── shared-types/ (its own package.json, tsconfig.json)
└── package.json (root)

Each package (`client`, `server`, `shared-types`) has its own `tsconfig.json` perfectly tailored for its environment. The `server` can be CommonJS, the `client` can be ESM, and the `shared-types` can be built to support both. Monorepo tooling manages the dependencies and build pipeline between them intelligently. It can build the `shared-types` package first, then build the `server` and `client` that depend on it in parallel.

Warning: This is absolutely overkill for a weekend project or a simple game jam. But for a production system that needs to scale and be maintained by a team, this is the gold standard. It enforces boundaries and makes dependencies explicit.

Summary: Which Path to Choose?

There’s no single right answer, only the right answer for your context. Here’s how I break it down for my teams:

Approach Complexity Best For…
1. Single Config + Bundler Low Prototypes, game jams, or small projects where you just need to get it working fast.
2. Dual `tsconfig` Files Medium The default for most serious, single-repository full-stack projects. It’s clean and scalable enough for most use cases.
3. Monorepo High Large-scale applications, multi-service architectures, or teams that need strong code-sharing and dependency management.

Don’t let the `tsconfig.json` file bully you. Understand the “why” behind the client/server split, pick a strategy that fits your project’s scale, and get back to what actually matters: building your game.

Darian Vance - Lead Cloud Architect

Darian Vance

Lead Cloud Architect & DevOps Strategist

With over 12 years in system architecture and automation, Darian specializes in simplifying complex cloud infrastructures. An advocate for open-source solutions, he founded TechResolve to provide engineers with actionable, battle-tested troubleshooting guides and robust software alternatives.


πŸ€– Frequently Asked Questions

❓ What is the core problem with TypeScript modules in full-stack game development?

The core problem is the conflict between Node.js servers, which traditionally use CommonJS (`require()`), and web browsers, which standardize on ES Modules (`import`). TypeScript’s `tsconfig.json` `module` property forces a choice, leading to `Error [ERR_REQUIRE_ESM]` when a single configuration tries to accommodate both environments.

❓ How do the three module resolution solutions compare in terms of complexity and use case?

The ‘Single Config + Bundler’ is low complexity, best for prototypes and game jams. The ‘Dual `tsconfig` Files’ is medium complexity, ideal for most serious, single-repository full-stack projects. The ‘Monorepo’ is high complexity, suited for large-scale applications, multi-service architectures, or teams needing strong code-sharing and dependency management.

❓ What is a common implementation pitfall when dealing with TypeScript module conflicts and how can it be avoided?

A common pitfall is attempting to use a single `tsconfig.json` with `module: “CommonJS”` for both client and server code. This often leads to `Error [ERR_REQUIRE_ESM]` when client-side dependencies are ESM-only or when the bundler struggles with the mixed module system. This can be avoided by explicitly separating client and server build configurations, either by letting a bundler handle client-side transformations or by implementing dual `tsconfig` files.

Leave a Reply

Discover more from TechResolve - SaaS Troubleshooting & Software Alternatives

Subscribe now to keep reading and get access to the full archive.

Continue reading