Running Angular without build tools, Node.js or npm
February 08, 2025
Introduction
Recently I have been experimenting with running TypeScript code in directly the browser, my tweet got a some attention, so I decided to write a blog post about it.
🤯 I pushed my Angular nobuild experiment a bit more and it works! I realized decorators are just functions that we can polyfill.
Here it is, Angular + TypeScript with no build step and no NPM, pure runtime in the browser. Isn't that really cool?! pic.twitter.com/880qnm1CYc
A bit of context
Node.js v22.6.0 introduced an interesting type stripping feature, which allows you to run TypeScript files without compiling them first. Additionally, the TypeScript team has been working to improve support for this feature with the erasable syntax flag, introduced in v5.8 beta.
These efforts push the nobuild workflow to the next level. But what about the front-end space? Can we run Angular applications without a build step?
Loading ES modules in the browser
In order to use Angular, we need to load modules in the browser at runtime, like so:
import { bootstrapApplication } from '@angular/platform-browser';
To be able to import ES Modules, we have to specify the type="module"
attribute in the script tag.
<script type="module">
/* Here we can import ES Modules */
</script>
Loading dependencies from a CDN using import maps
For a modern approach, we can leverage import maps to map the module names to actual files served remotely via an URL.
<script type="importmap">
{
"imports": {
"@angular/core": "https://ga.jspm.io/npm:@angular/[email protected]/fesm2022/core.mjs",
"@angular/core/primitives/signals": "https://ga.jspm.io/npm:@angular/[email protected]/fesm2022/primitives/signals.mjs",
"@angular/core/primitives/event-dispatch": "https://ga.jspm.io/npm:@angular/[email protected]/fesm2022/primitives/event-dispatch.mjs",
"@angular/compiler": "https://ga.jspm.io/npm:@angular/[email protected]/fesm2022/compiler.mjs",
"@angular/platform-browser": "https://ga.jspm.io/npm:@angular/[email protected]/fesm2022/platform-browser.mjs",
"@angular/common": "https://ga.jspm.io/npm:@angular/[email protected]/fesm2022/common.mjs",
"@angular/common/http": "https://ga.jspm.io/npm:@angular/[email protected]/fesm2022/http.mjs",
"rxjs": "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
"rxjs/operators": "https://cdn.jsdelivr.net/npm/[email protected]/operators/+esm"
}
}
</script>
This way we get rid of the need to install and bundle the Angular dependencies, and we can load them directly from a CDN.
Loading ES modules from TypeScript files
Now that we have the Angular modules loaded, we want to be able to load our TypeScript source files as ES Modules. We can do this by using the es-module-shims polyfill library.
It is quite simple to use, we just need to include the library in our HTML file, and enable the TypeScript support.
<script async src="https://ga.jspm.io/npm:[email protected]/dist/es-module-shims.js"></script>
<script type="esms-options">
{ "polyfillEnable": ["typescript"] }
</script>
Under the hood, ES Module Shims will strip the types using WebAssembly, and load the resulting JavaScript code in the browser.
![Fetch requests](/static/d7ac7c3828f4fdbce9fb7a2f1923b8bd/487bb/fetch-requests.png)
Bootstrapping the Angular application
Finally, we can bootstrap our Angular application by calling the bootstrapApplication
function and passing the AppComponent
.
<body>
<script type="module" lang="ts">
import "@angular/compiler";
import { bootstrapApplication } from "@angular/platform-browser";
import { provideExperimentalZonelessChangeDetection } from "@angular/core";
import { AppComponent } from "./app.component.ts";
bootstrapApplication(AppComponent, {
providers: [provideExperimentalZonelessChangeDetection()],
}).catch((err) => console.error(err));
</script>
<app-root></app-root>
</body>
To run Angular without build step we have to import the @angular/compiler
module to enable JIT compilation. Of course, this has a performance impact, but it can be useful for development purposes, or highly dynamic applications.
.ts
or .mts
in the import statement.Decorators and metadata
By default Angular uses experimental decorators and metadata to define components, services, and other entities.
@Component({
selector: 'app-root',
template: '<h1>{{ title() }}</h1>',
styles: `h1 { text-align: center; }`,
})
export class AppComponent {
readonly title = signal('Hello, Angular!');
}
However these are not supported in the browser, resulting in a runtime error:
Uncaught SyntaxError: Invalid or unexpected token (at app.component.ts:22:5)
To work around this, we can polyfill decorators using the following _ts_decorate
function.
function _ts_decorate(decorators, target, key, desc) {
var c = arguments.length,
r =
c < 3
? target
: desc === null
? (desc = Object.getOwnPropertyDescriptor(target, key))
: desc,
d: (arg0: any, arg1: undefined, arg2: undefined) => any;
if (typeof Reflect === 'object' && typeof Reflect.decorate === 'function')
r = Reflect.decorate(decorators, target, key, desc);
else
for (var i = decorators.length - 1; i >= 0; i--)
if ((d = decorators[i]))
r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
}
export const AppComponent = _ts_decorate(
[
Component({
selector: 'app-root',
template: '<h1>{{ title() }}</h1>',
styles: `h1 { text-align: center; }`,
}),
],
class {
readonly title = signal('Hello, Angular!');
}
);
This helper function is produced by TypeScript when compiling decorators, and it can be used to apply decorators at runtime.
That’s it! 🚀 We have now an Angular application up and running, with no build step, no Node.js, and no npm.
Demo
What about standard ES decorators?
Angular almost fully supports standard ES decorators, but only if you stick to the inject
function for dependency injection. See this PR for more details.
However the Decorators and Decorators Metadata proposals and are still in stage 3, and it’s not clear when it will be finalized and implemented in browsers.
![Browser support](/static/46eb13d020862aff361f6cc7771d96e7/487bb/browser-support.png)
I looked at the generated code to polyfill ES decorators, but it’s quite complex compared to experimental decorators. For instance the following code:
@Component
class test {}
Is transpiled to:
"use strict";
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
var _, done = false;
for (var i = decorators.length - 1; i >= 0; i--) {
var context = {};
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
if (kind === "accessor") {
if (result === void 0) continue;
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
if (_ = accept(result.get)) descriptor.get = _;
if (_ = accept(result.set)) descriptor.set = _;
if (_ = accept(result.init)) initializers.unshift(_);
}
else if (_ = accept(result)) {
if (kind === "field") initializers.unshift(_);
else descriptor[key] = _;
}
}
if (target) Object.defineProperty(target, contextIn.name, descriptor);
done = true;
};
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
var useValue = arguments.length > 2;
for (var i = 0; i < initializers.length; i++) {
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
}
return useValue ? value : void 0;
};
let test = (() => {
let _classDecorators = [Component];
let _classDescriptor;
let _classExtraInitializers = [];
let _classThis;
var test = class {
static { _classThis = this; }
static {
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
test = _classThis = _classDescriptor.value;
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
__runInitializers(_classThis, _classExtraInitializers);
}
};
return test = _classThis;
})();
Which is harder to implement than the experimental decorators polyfill. I hope that the decorators proposal will be finalized soon, so we can use standard ES decorators directly the browser.