Bundling with esbuild without abandoning plain-JS habits
Small front-end projects often follow a simple pattern: an index.html, a script.js, and a single <script src="script.js"> tag. That setup works until you want to use a library whose installation instructions assume a build step and import statements. For a while, that was a blocker—until esbuild made the gap much easier to cross.
Why a familiar setup stops working
Many JavaScript libraries document usage like this:
npm install vue-jcrop
with code examples that start with:
import { Jcrop } from 'vue-jcrop';
If you’ve never used a bundler, that import statement is a mystery. The obvious answer is to adopt the project generator for your framework of choice—vue create, for instance—but those tools wrap a lot of moving parts. A Vue CLI service typically handles template compilation, Babel, possibly TypeScript checks, and then delegates the heavy lifting to webpack, a plugin ecosystem that’s hard to reason about when something breaks.
For small projects, it’s not worth adopting a toolchain that’s difficult to troubleshoot. esbuild offers a middle ground: it resolves imports and bundles everything into a single file, without requiring an entire scaffolding pipeline.
Getting a library working in four steps
Using esbuild to pull in Vue or another library is straightforward:
1. Install the library with npm install vue.
2. Import it in your script:
import Vue from 'vue';
3. Run esbuild to produce a bundle:
$ esbuild script.js --bundle --minify --outfile=bundle.js
4. Point your HTML at the output by replacing all script tags with a single <script src="bundle.js">.
In theory, everything should work. In practice, the first attempt often fails.
The runtime-only build trap
If you compile Vue templates at runtime, the first esbuild attempt produces a console warning:
[Vue warn]: You are using the runtime-only build of Vue where the template compiler is not available. Either pre-compile the templates into render functions, or use the compiler-included build.
The reason is that frontend libraries often ship multiple build artifacts. For Vue, those differ along a few axes:
- dev versus prod builds (affecting error message verbosity)
- runtime-only versus full builds (whether the template compiler is included)
- module format:
vue.jsversusvue.esm.jsversusvue.common.jsrelate to ES modules versus CommonJS
When you import Vue from 'vue', a bundler doesn’t automatically pick the version with the template compiler. In Vue’s case, tooling such as vue-cli-service uses a config file to steer the import toward a specific artifact:
webpackConfig.resolve
.alias
.set(
'vue$',
options.runtimeCompiler
? 'vue/dist/vue.esm.js'
: 'vue/dist/vue.runtime.esm.js'
)
That snippet directs the build to vue.esm.js when the template compiler is needed, rather than the default vue.runtime.esm.js.
To confirm which file esbuild was actually loading, tracing open files (via strace) on a file containing just:
import Vue from 'vue';
const app = new Vue()
shows it opens the package’s package.json and then vue.runtime.esm.js. The main key in package.json is what tells the bundler which artifact to load by default.
"main": "dist/vue.runtime.common.js",
The fix is a one-line import change
Switching to the compiler-included build resolves everything. Replace:
import Vue from 'vue'
with:
import Vue from 'vue/dist/vue.esm.js'
Run esbuild again, and the bundle works. That command can live in a small bash script, which fits a tiny-project workflow nicely.
What npm install actually delivers
A useful detail: when a maintainer publishes a package, they typically run npm run build first, and the resulting artifacts are what npm install downloads into node_modules. That’s why after installing Vue you get a dist/ directory with all its build variants.
Not every package follows that pattern. The friendly-words package from Glitch, for instance, has no dist/ directory. It’s intended for backend use, so it reads its data files from disk at runtime rather than bundling them:
$ cat node_modules/friendly-words/index.js
const data = require('./generated/words.json');
exports.objects = data.objects;
exports.predicates = data.predicates;
exports.teams = data.teams;
exports.collections = data.collections;
Choosing import over require
There are two module systems in play. require is CommonJS; import is ES6 modules. The distinction matters for browser code because require loads synchronously, and browsers can’t do that at runtime. import is also more restricted, which generally makes it the safer choice for frontend code.
esbuild collapses all those imports into one file during the build step, so you get the module syntax you want without asking the browser to resolve anything.
Why esbuild fits small projects
esbuild is a static Go binary, which makes it feel more predictable to work with than tooling written in JavaScript. It’s also fast. The value isn’t in enabling huge, complicated frontends—it’s in letting a plain-JavaScript project use import statements without adopting a build system you don’t understand.



