Using Three.js With Typescript & ES Modules (Without a NodeJS Server)

Last Updated: February 20, 2021 Reading time: 3 min (~800 words)

I was recently experimenting with three.js (once again), and decided it was a good time to start using Typescript to organise my projects better. After all, I’m always greeted with uncertainty on how to structure my JS prototypes, so I thought this would solve a few decisions for me.

The three.js docs suggest you use ES modules for your app code, and don’t have any special instructions for Typescript projects (apart from the tsconfig.json configuration).

My initial attempt was this:

import * as THREE from 'three';

class App {
	init() {
		const scene = new THREE.Scene();

Running the Typescript compiler produces a very similar looking JS file:

import * as THREE from 'three';

class App {
	init() {
		const scene = new THREE.Scene();

The import statement at the top doesn’t know how to find the three module under node_modules, so this won’t work.

My second attempt was to define the module relative to the current path (assuming the typescript file is at the project root).

import * as THREE from './node_modules/three/build/three.module.js'

This gets copied as-is to the JS output, and resolves the ES module correctly. It’s also surprising to me that the Typescript compiler doesn’t complain about including a JS module, but perhaps it pulls the TS definitions automagically.

So far, this looked like a promising solution.

But what happens if your project structure is different, and your Typescript source is in a different folder than the output files?

Hidden costs of relative module imports

In theory, if your folder structure looks something like this:


then you could use the above import method to point to the node_modules directory:

import * as THREE from '../../node_modules/three/build/three.module.js'

Apart from the obvious inconvenience of typing the correct number of dots, this quickly breaks down if your input (TS files) and output (JS files) have different tree depths.

Typescript doesn’t seem to be doing any conversion between module import paths, so at this point I couldn’t figure out any trickery to ensure the paths work both on the Typescript & Javascript side.

Of course, I’ve been also doing some research while trying my luck, to see what other people did to get their Typescript projects running with the three.js modules.

Common solutions on the web

Most three.js + typescript solutions on the web employ one of these two strategies:

I’m not a big fan of using NodeJS for everything, because:

So I went the webpack way.

Bundling the three.js app with Webpack

Up to this point, I was trying to avoid having to learn another tool for my ultra-simple web projects, but here we are.

The way I saw it, is that going through that initial configuration pain would keep me happy in the future, not having to fiddle with my import paths.

In effect, the webpack tool will take something like

import * as THREE from 'three'

and convert it to

import * as THREE from '../node_modules/three/build/three.module.js'

Not only that, but it will bundle your app with the entire three.js library, so you only have to include a single JS file at the end.

My webpack.config.js is super simple, and I’m sure there are vastly better setups for this, but here goes:

const path = require('path');

module.exports = {
	mode: "development",
	devtool: 'inline-source-map',
	entry: './src/app.ts',
	module: {
		rules: [
				test: /\.tsx?$/,
				use: 'ts-loader',
				exclude: /node_modules/,
	resolve: {
		extensions: ['.tsx','.ts','.js'],
	output: {
		filename: 'bundle.js',
		path: path.resolve(__dirname, 'dist')

Here’s a Github repo that implements the same idea as a boilerplate project:


I now have a simple & robust(-ish) build system in place, while avoiding having to spin off a server as part of my prototyping work.

Hope the same seems helpful to anyone that is looking for the fastest way to get from code to pixels 😉!