Primate Logo Primate

Angular

Primate runs Angular with server-side rendering, hydration, client navigation, layouts, validation and i18n.

Unlike most frontends that Primate runs, Angular is a full-stack framework. To achieve parity with other frontends, Primate uses only the client portion and provides the server implementation itself.

Setup

Install

npm install @primate/angular @angular/core @angular/common @angular/compiler

Install additional packages like @angular/forms as needed.

Configure

import config from "primate/config";
import angular from "@primate/angular";

export default config({
  modules: [
    angular(),
  ],
});

Components

Create Angular components in views.

// views/PostIndex.component.ts
import { Component, input } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  imports: [CommonModule],
  template: `
    <h1>{{ title() }}</h1>
    <article *ngFor="let post of posts()">
      <h2>{{ post.title }}</h2>
      @if (post.excerpt) {
        <p>{{ post.excerpt }}</p>
      }
    </article>
  `,
})
export default class PostIndex {
  title = input("Blog");
  posts = input<{ title: string; excerpt?: string }[]>([]);
}

Components only require a selector when referenced by tag from another component's template. Pages rendered from routes and layouts are instantiated programmatically and do not need a selector.

When referencing child components by tag (e.g., <app-link>), the child must declare a selector and the parent must include the child in imports: [Child]. Retain CommonModule when using *ngFor.

Serve the component from a route.

// routes/posts.ts
import PostIndex from "#view/PostIndex";
import response from "primate/response";
import route from "primate/route";

export default route({
  get() {
    const posts = [
      { title: "First Post", excerpt: "Introduction to Primate with Angular" },
      { title: "Second Post", excerpt: "Building reactive applications" },
    ];

    return response.view(PostIndex, { title: "Blog", posts });
  },
});

Props

Props passed to response.view are mapped to input() signals inside Angular components.

Pass props from a route:

import UserView from "#view/User";
import response from "primate/response";
import route from "primate/route";

export default route({
  get() {
    return response.view(UserView, {
      user: { name: "John", role: "Developer" },
      permissions: ["read", "write"],
    });
  },
});

These props become input() properties in the component:

import { Component, input } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  imports: [CommonModule],
  template: `
    <div>
      <h2>{{ user().name }}</h2>
      <p>Role: {{ user().role }}</p>
      <ul>
        <li *ngFor="let permission of permissions()">{{ permission }}</li>
      </ul>
    </div>
  `,
})
export default class User {
  user = input();
  permissions = input<string[]>([]);
}

Request

Import the request signal from app:angular to access the current request inside any component. The signal updates automatically on client-side navigation.

import { Component } from "@angular/core";
import { request } from "app:angular";

@Component({
  template: `<p>Current path: {{ request().url.pathname }}</p>`,
})
export default class Page {
  request = request;
}

The request signal exposes a RequestPublic object.

Property Type Description
url URL current request URL
query Dict<string> query string parameters
headers Dict<string> request headers
cookies Dict<string> request cookies

Reactivity with signals

Angular's signals provide fine-grained reactivity for state management and computed values.

import { Component, signal, computed } from "@angular/core";

@Component({
  template: `
    <div>
      <button (click)="decrement()">-</button>
      <span>{{ count() }}</span>
      <button (click)="increment()">+</button>
      <p>Doubled: {{ doubled() }}</p>
    </div>
  `,
})
export default class Counter {
  count = signal(0);
  doubled = computed(() => this.count() * 2);

  increment() {
    this.count.update(n => n + 1);
  }

  decrement() {
    this.count.update(n => n - 1);
  }
}

Validation

Use Primate's validated state wrapper to synchronize with backend routes.

import { Component, OnInit, computed, input } from "@angular/core";
import { client } from "@primate/angular";

@Component({
  template: `
    <h2>Counter</h2>
    <button (click)="decrement()" [disabled]="c().loading()">-</button>
    <span>{{ c().value() }}</span>
    <button (click)="increment()" [disabled]="c().loading()">+</button>

    @if (c().error()) {
      <p style="color:red">{{ c().error().message }}</p>
    }
  `,
})
export default class Counter {
  id = input<string>("");
  counter = input<number>(0);
  c = computed(() => client.field(this.counter()).post(`/counter?id=${this.id()}`));

  increment() { this.c().update(n => n + 1); }
  decrement() { this.c().update(n => n - 1); }
}

Add corresponding backend validation in the route:

// routes/counter.ts
import CounterView from "#view/Counter";
import Counter from "#store/Counter";
import route from "primate/route";
import response from "primate/response";
import p from "pema";

await Counter.create();

export default route({
  async get() {
    const [existing] = await Counter.find({});
    const counter = existing ?? await Counter.insert({ value: 10 });

    return response.view(CounterView, {
      id: counter.id,
      counter: counter.value,
    });
  },
  async post(request) {
    const id = p.string.parse(request.query.get("id"));
    const body = p.loose({ value: p.number }).parse(await request.body.form());
    await Counter.update(id, { set: { value: body.value } });
    return null;
  },
});

Forms

Create the form view:

import { Component, OnInit, input } from "@angular/core";
import { client } from "@primate/angular";
import route from "#route/login";

@Component({
  template: `
    @if (form) {
      <form [id]="form.id" method="post" (submit)="form.submit($event)">
        <input name="email" placeholder="Email" />
        @if (form.field('email').error()) {
          <div>{{ form.field('email').error() }}</div>
        }

        <input name="password" type="password" placeholder="Password" />
        @if (form.field('password').error()) {
          <div>{{ form.field('password').error() }}</div>
        }

        @if (form.submitted()) {
          <p>Logged in successfully.</p>
        }

        <button type="submit" [disabled]="form.submitting()">Submit</button>
      </form>
    }
  `,
})
export default class LoginForm implements OnInit {
  form!: ReturnType<typeof client.form>;

  ngOnInit() {
    this.form = client.form(route.post, {
      initial: { email: "", password: "" },
    });
  }
}

Add the corresponding route:

// routes/login.ts
import LoginForm from "#view/LoginForm";
import p from "pema";
import response from "primate/response";
import route from "primate/route";

export default route({
  get() {
    return response.view(LoginForm);
  },
  post: route.with({
    contentType: "application/json",
    body: p({ email: p.string.email(), password: p.string.min(8) }),
  }, async request => {
    const { email, password } = await request.body.json();
    // implement authentication logic
    return null;
  }),
});

Layouts

For SSR with hydration, layouts accept a slot: TemplateRef and render it using *ngTemplateOutlet. Note that slot must remain as @Input() since it is a template reference passed internally by Angular, not a regular prop.

Create a layout view:

// views/Layout.component.ts
import { Component, Input, TemplateRef } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  imports: [CommonModule],
  template: `
    <header>
      <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
      </nav>
    </header>

    <main>
      <ng-container *ngTemplateOutlet="slot"></ng-container>
    </main>

    <footer>© 1996 My App</footer>
  `,
})
export default class Layout {
  @Input({ required: true }) slot!: TemplateRef<unknown>;
}

Next, register the layout using a +layout.ts file:

// routes/+layout.ts
import Layout from "#view/Layout";
import response from "primate/response";
import route from "primate/route";

export default route({
  get() {
    return response.view(Layout);
  },
});

Passing Props to Layouts

// views/Layout.component.ts
import { Component, Input, TemplateRef, input } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  imports: [CommonModule],
  template: `
    <header>
      <h1>{{ brand() }}</h1>
      <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
      </nav>
    </header>

    <main>
      <ng-container *ngTemplateOutlet="slot"></ng-container>
    </main>
  `,
})
export default class Layout {
  @Input({ required: true }) slot!: TemplateRef<unknown>;
  brand = input("My App");
}
// routes/+layout.ts
import Layout from "#view/Layout";
import response from "primate/response";
import route from "primate/route";

export default route({
  get() {
    return response.view(Layout, { brand: "Primate Angular Demo" });
  },
});

Internationalization

Primate's t function is framework-agnostic. In Angular, call it directly:

import { Component } from "@angular/core";
import t from "#i18n";

@Component({
  template: `
    <h1>{{ t("welcome") }}</h1>
    <button (click)="setLocale('en-US')">{{ t("english") }}</button>
    <button (click)="setLocale('de-DE')">{{ t("german") }}</button>
    <p>{{ t("current_locale") }}: {{ currentLocale() }}</p>
  `,
})
export default class Welcome {
  t = (key: string) => t(key);

  setLocale(locale: string) {
    t.locale.set(locale);
  }

  currentLocale() {
    return t.locale.get();
  }
}

Primate's integration automatically subscribes to locale changes and triggers rerenders when switching languages.

Head Tags

Use Angular's Title and Meta to dynamically set page titles and meta tags.

import type { OnInit } from "@angular/core";
import { Component, inject } from "@angular/core";
import { Meta, Title } from "@angular/platform-browser";

@Component({
  template: "<h1>{{ pageTitle }}</h1>",
})
export default class Page implements OnInit {
  pageTitle = "About Us";
  title = inject(Title);
  meta = inject(Meta);

  ngOnInit() {
    this.title.setTitle(this.pageTitle);
    this.meta.addTag({ name: "description", content: "Learn more about us" });
    this.meta.addTag({ property: "og:title", content: this.pageTitle });
  }
}

Configuration

Option Type Default Description
extensions string[] [".component.ts"] Associated file extensions
ssr boolean true Enable server-side rendering
csr boolean true Enable client-side rendering

Example

import config from "primate/config";
import angular from "@primate/angular";

export default config({
  modules: [
    angular({
      extensions: [".component.ts", ".ng.ts"],
    }),
  ],
});

Resources

Previous
Intro
Next
Eta