Angular in a single HTML file

Angular is normally a TypeScript project with a build step. For a small page, three imports from a CDN and one function call are enough to run it from a single HTML file.

Here, "Angular" means the web framework documented at angular.dev and maintained by a team at Google.

A normal Angular app is a TypeScript project that the Angular CLI builds. A small one also runs from one HTML file: import three Angular packages from jsDelivr and the browser compiles the template when the page opens.

Try it first. The counter below is Angular 22.2.1, loaded from a CDN, with no install and no build.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Angular in one HTML file</title>
<style>
  body { margin: 0; padding: 24px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  .card { max-width: 320px; padding: 18px 20px; border-radius: 12px; background: #fff; box-shadow: 0 6px 20px rgba(0, 0, 0, .1); }
  button { font: inherit; padding: 8px 16px; border: 0; border-radius: 8px; background: #c3002f; color: #fff; cursor: pointer; }
  p { margin: 0 0 12px; }
</style>
</head>
<body>
<!-- Angular replaces what is inside this tag once it starts -->
<app-root>Loading Angular...</app-root>

<script type="module">
  // The compiler must load first: it turns the template string into code in the browser
  import 'https://cdn.jsdelivr.net/npm/@angular/compiler@22.2.1/+esm';
  import { Component, signal } from 'https://cdn.jsdelivr.net/npm/@angular/core@22.2.1/+esm';
  import { bootstrapApplication } from 'https://cdn.jsdelivr.net/npm/@angular/platform-browser@22.2.1/+esm';

  class App {
    count = signal(0);  // a value the template reads by calling count()
  }

  // No TypeScript here, so Component() is called on the class instead of written as @Component
  Component({
    selector: 'app-root',
    template: `
      <div class="card">
        <p>You clicked <b>{{ count() }}</b> times.</p>
        <button (click)="count.set(count() + 1)">Click me</button>
      </div>`,
  })(App);

  bootstrapApplication(App);
</script>
</body>
</html>
A complete Angular page in one file. Edit the template or the class and the example reruns.

The whole app is the module script at the bottom. The rest of this guide takes it apart, shows the template syntax you will use most, and explains what changes when you move to a real Angular project.

The four parts of the page

Every single-file Angular page has the same four parts, in this order.

A placeholder tag, three imports, a class with its template, and one call that starts it.
A placeholder tag, three imports, a class with its template, and one call that starts it.
  1. A placeholder tag. Put <app-root> in the body with some text inside. The text shows until Angular starts, so if you still see it, Angular did not start.
  2. Three imports in a module script. @angular/compiler comes first, then @angular/core and @angular/platform-browser. The script tag needs type="module", or import is a syntax error.
  3. A class and its template. State lives on the class as signals. Component() gets a selector, which must match the placeholder tag, and a template string.
  4. One call to start it. bootstrapApplication(App) finds the tag and renders the template inside it.

Pin every Angular package to the same version number. The @angular/core 22.2.1 package lists @angular/compiler 22.2.1, an exact version, as a peer dependency.

Why it runs without a build

Angular has two ways to compile templates. AOT (ahead of time) compiles them at build time, and it is the default for ng build and ng serve. JIT (just in time) compiles them in the browser while the page runs.

The single file compiles in the browser. A CLI project compiles once, before anyone opens the page.
The single file compiles in the browser. A CLI project compiles once, before anyone opens the page.

A single HTML file has no build, so it uses JIT. That is why @angular/compiler is imported first: without it, Angular has nothing to turn the template string into code.

The Angular docs note that the compiler is a large part of Angular, so an AOT build that leaves it out ships much less code.

There is also no zone.js script: the Angular docs say change detection without zone.js is the default from v21. Updating a signal that the template reads, or running a listener bound in the template, tells Angular to refresh the view.

The template syntax you will use most

Templates are HTML with a few additions. This example uses most of them. Type in the box to filter the list.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Angular template syntax</title>
<style>
  body { margin: 0; padding: 20px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  .card { max-width: 360px; padding: 16px 18px; border-radius: 12px; background: #fff; box-shadow: 0 6px 20px rgba(0, 0, 0, .1); }
  input { font: inherit; padding: 7px 9px; border: 1px solid #c9ced6; border-radius: 8px; width: 100%; box-sizing: border-box; }
  ul { padding-left: 20px; }
  li.hit { background: #fff3c4; }
  .note { color: #6b7280; font-size: 14px; }
</style>
</head>
<body>
<app-root>Loading Angular...</app-root>

<script type="module">
  import 'https://cdn.jsdelivr.net/npm/@angular/compiler@22.2.1/+esm';
  import { Component, signal, computed } from 'https://cdn.jsdelivr.net/npm/@angular/core@22.2.1/+esm';
  import { bootstrapApplication } from 'https://cdn.jsdelivr.net/npm/@angular/platform-browser@22.2.1/+esm';

  class App {
    fruits = ['Apple', 'Banana', 'Cherry', 'Grape', 'Mango', 'Pear'];
    query = signal('');
    // computed() re-runs only when query changes
    matches = computed(() =>
      this.fruits.filter(f => f.toLowerCase().includes(this.query().toLowerCase())));
  }

  Component({
    selector: 'app-root',
    template: `
      <div class="card">
        <!-- [value] sets a property; (input) listens to an event, $event is the DOM event -->
        <input placeholder="Filter fruit" [value]="query()" (input)="query.set($event.target.value)">

        <!-- {{ }} prints a value -->
        <p class="note">{{ matches().length }} of {{ fruits.length }} shown</p>

        <ul>
          <!-- @for needs track so Angular can reuse list items -->
          @for (f of matches(); track f) {
            <li [class.hit]="query() !== ''">{{ f }}</li>
          } @empty {
            <li>No fruit matches "{{ query() }}"</li>
          }
        </ul>

        @if (query()) {
          <button (click)="query.set('')">Clear filter</button>
        }
      </div>`,
  })(App);

  bootstrapApplication(App);
</script>
</body>
</html>
Interpolation, a property binding, an event binding, @for with @empty, and @if, in one small filter.
Syntax What it does In the example
{{ value }} Prints a value as text The "6 of 6 shown" line
[property]="expr" Sets a DOM property [value] on the input
[class.name]="expr" Adds the class when the value is truthy [class.hit] on each item
(event)="statement" Runs code when the event fires (input) and (click)
$event The DOM event inside a listener Reads the typed text
@if (cond) { } Shows a block only when true The Clear button
@for (x of list; track x) { } Repeats a block for each item The fruit list
@empty { } Shown when the @for list is empty The "No fruit matches" line

Two parts of the class drive it. query is a writable signal: read it with query(), change it with query.set(...). matches is a computed() signal, which recalculates when query changes.

query = signal('');
matches = computed(() =>
  this.fruits.filter(f => f.toLowerCase().includes(this.query().toLowerCase())));

track is required in @for. Leave it out and the template fails to compile, so the page stays on its loading text.

Writing a component without TypeScript

In a CLI project, a component is a TypeScript class with @Component written above it, and the template often lives in its own file. A browser running a plain .html file has no TypeScript step and no second file to load.

The same component options, written as a decorator in TypeScript and as a function call in plain JavaScript.
The same component options, written as a decorator in TypeScript and as a function call in plain JavaScript.

So the single file calls Component() with the same options and passes it the class:

class App {
  count = signal(0);
}

Component({
  selector: 'app-root',
  template: `<button (click)="count.set(count() + 1)">{{ count() }}</button>`,
})(App);

Use template with a backtick string, not templateUrl. Backticks let the template span several lines. If you later move the code into a CLI project, the options carry over as they are.

A finished example: a packing list

This one adds a form, checkboxes and removal. Add an item with Enter or the button, tick items off, and remove them with the cross.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Packing list in Angular</title>
<style>
  body { margin: 0; padding: 20px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  .card { max-width: 380px; padding: 16px 18px; border-radius: 12px; background: #fff; box-shadow: 0 6px 20px rgba(0, 0, 0, .1); }
  h2 { margin: 0 0 4px; font-size: 20px; }
  .note { margin: 0 0 12px; color: #6b7280; font-size: 14px; }
  form { display: flex; gap: 8px; }
  input[type=text] { flex: 1; min-width: 0; font: inherit; padding: 7px 9px; border: 1px solid #c9ced6; border-radius: 8px; }
  button { font: inherit; padding: 7px 12px; border: 0; border-radius: 8px; background: #c3002f; color: #fff; cursor: pointer; }
  ul { list-style: none; padding: 0; margin: 12px 0 0; }
  li { display: flex; align-items: center; gap: 8px; padding: 7px 0; border-bottom: 1px solid #eceef2; }
  li label { flex: 1; }
  li.done label { color: #9ca3af; text-decoration: line-through; }
  .x { background: none; color: #9ca3af; padding: 2px 8px; }
  .all { margin-top: 12px; color: #0f5132; font-weight: 600; }
</style>
</head>
<body>
<app-root>Loading Angular...</app-root>

<script type="module">
  import 'https://cdn.jsdelivr.net/npm/@angular/compiler@22.2.1/+esm';
  import { Component, signal, computed } from 'https://cdn.jsdelivr.net/npm/@angular/core@22.2.1/+esm';
  import { bootstrapApplication } from 'https://cdn.jsdelivr.net/npm/@angular/platform-browser@22.2.1/+esm';

  let nextId = 4;

  class App {
    items = signal([
      { id: 1, name: 'Passport', done: true },
      { id: 2, name: 'Charger', done: false },
      { id: 3, name: 'Rain jacket', done: false },
    ]);
    left = computed(() => this.items().filter(i => !i.done).length);

    add(event, input) {
      event.preventDefault();  // keep the form from reloading the page
      const name = input.value.trim();
      if (!name) return;
      this.items.update(list => [...list, { id: nextId++, name, done: false }]);
      input.value = '';
    }
    toggle(id) {
      // replace the list (not mutate it) so the signal sees a new value
      this.items.update(list => list.map(i => i.id === id ? { ...i, done: !i.done } : i));
    }
    remove(id) {
      this.items.update(list => list.filter(i => i.id !== id));
    }
  }

  Component({
    selector: 'app-root',
    template: `
      <div class="card">
        <h2>Packing list</h2>
        <p class="note">{{ left() }} of {{ items().length }} still to pack</p>

        <!-- #box names the input so the template can pass it to add() -->
        <form (submit)="add($event, box)">
          <input #box type="text" placeholder="Add an item" aria-label="New item">
          <button>Add</button>
        </form>

        <ul>
          @for (item of items(); track item.id) {
            <li [class.done]="item.done">
              <input type="checkbox" [id]="'i' + item.id" [checked]="item.done" (change)="toggle(item.id)">
              <label [for]="'i' + item.id">{{ item.name }}</label>
              <button class="x" (click)="remove(item.id)" aria-label="Remove">✕</button>
            </li>
          }
        </ul>

        @if (items().length && left() === 0) {
          <p class="all">All packed.</p>
        }
      </div>`,
  })(App);

  bootstrapApplication(App);
</script>
</body>
</html>
One component: a form that adds items, checkboxes that tick them off, and a count that keeps up.
  • The form does not reload the page. (submit)="add($event, box)" passes the event, and add() calls event.preventDefault(). The Angular docs recommend calling it explicitly.
  • #box names the input so the template can hand the element to add(), which reads and clears its value.
  • Every change makes a new array. add(), toggle() and remove() all call items.update() and return a new list. The signal gets a new value, so the left count recalculates.
  • track item.id lets Angular keep the right row when items are removed from the middle.

If you have built a to-do list in plain JavaScript, compare the two. The Angular version has no innerHTML and no code that finds rows to update; the template describes the page for any state.

Moving to an Angular CLI project

A single file is good for learning, a prototype, or a small tool. For an app with many components, routing and tests, the Angular docs start you with the CLI, which needs Node.js:

npm install -g @angular/cli
ng new my-app
cd my-app
npm start

The dev server runs at http://localhost:4200.

ng build compiles TypeScript and templates ahead of time and writes the result to dist/my-app by default. That output is a folder of files, not one HTML page, so it is hosted like any built front end.

Hosting a built front end covers the base path and refresh errors that come up there.

To try the CLI-style code without installing anything, the Angular site has a browser Playground.

Angular or AngularJS?

Older tutorials that use ng-app and ng-model with a single angular.js script are AngularJS. It is a separate, earlier framework, and its site says support ended in January 2022.

AngularJS Angular
Start-up ng-app attribute on an element bootstrapApplication(App)
Template loops ng-repeat @for
Docs angularjs.org angular.dev

Code from one does not run in the other. Everything on this page is Angular.

When it does not work

Open the browser console first. When Angular fails to start, the error is there and the page still shows its loading text.

What you see Cause Fix
Stuck on "Loading", console says the compiler is not available @angular/compiler not imported Import it first, before core
Stuck on "Loading", "Cannot use import statement outside a module" Script tag has no type Use <script type="module">
Stuck on "Loading", error NG05104 The selector does not match any element Make selector and the tag name the same
Stuck on "Loading", "@for loop must have a track expression" track left out Add track item.id or track $index
The list grows but a computed count stays wrong The array was changed with push Use update() and return a new array
Pressing Enter reloads the page and the new item is gone The form submitted to the page itself Call event.preventDefault() in the handler

If no error appears at all, why JavaScript is not working in an HTML file goes through the checks that apply to any page.

An Angular page is easier to show than to describe. A screenshot cannot be clicked, and an .html attachment may open as plain code, or not at all, on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written, its scripts run and the Angular imports load from jsDelivr, so the people you send it to can use the app themselves without an account. If you change the code later, the same link shows the new version.

Questions people ask

Can Angular run without Node.js, npm or the Angular CLI?

Yes, for a small page. Import @angular/compiler, @angular/core and @angular/platform-browser from a CDN in a module script, and the browser compiles the template when the page opens. The examples on this page work that way. Larger apps are built with the Angular CLI.

Why does my Angular page stay on "Loading..."?

Angular never started, so the placeholder text inside the root tag is still there. Open the browser console. The usual causes are a missing compiler import, a script tag without type="module", a selector that does not match the tag, or an @for loop with no track expression.

Do I need zone.js?

Not with current Angular. The Angular docs say zoneless change detection is the default from v21, and the examples here load no zone.js at all. Signals and event bindings in the template tell Angular when to update the page.

Is this the same as AngularJS with ng-app?

No. AngularJS is the older framework that used ng-app and a single angular.js script. Its site says support ended in January 2022. The code on this page is for Angular, the framework documented at angular.dev.

Can I write @Component in a plain HTML file?

The examples here avoid it. They are plain JavaScript with no TypeScript step, so they call Component() with the same options and pass it the class. A CLI project in TypeScript writes @Component above the class instead.

Keep reading