AbsoluteJS

Angular

Build server-rendered Angular applications with full type safety, zoneless change detection, and automatic hydration.

#Build Configuration

Add Angular to your build by specifying the directory containing your Angular components in absolute.config.ts. The optional angular.providers field declares the global DI provider array every Angular page on this server receives at SSR and client bootstrap — the place for cross-cutting concerns like provideHttpClient, error handlers, interceptors, and locale providers:

TS
// absolute.config.ts
import { defineConfig } from '@absolutejs/absolute';
import { appProviders } from './src/angular/appProviders';

export default defineConfig({
  angularDirectory: 'src/angular',
  // Optional. Pass a real typed value (not a string path) so TS catches
  // a missing import or renamed binding. The framework AST-parses this
  // file at build time to find the source of `appProviders`, then
  // injects a matching `import { appProviders } from "..."` plus an
  // `export const providers = [...appProviders, /* router, base-href */]`
  // directly into every page's compiled server output. SSR reads
  // `pageModule.providers`, the client wrapper reads it from the same
  // module — single `@angular/core` instance for page + providers.
  angular: { providers: appProviders }
});
1
Write the providers as a real typed value
Not a string path — TypeScript catches a missing import or renamed binding at compile time.
2
The build AST-parses absolute.config.ts
The framework finds the import path of the binding referenced here.
3
Providers land in compiled output
A matching import + an export const providers = [...] declaration is baked directly into every page's compiled server output.
4
Per-page additions are auto-wired
Additions like provideRouter(routes) and APP_BASE_HREF come from page-level signals — you never write either yourself. See Provider Model and Routing for the full auto-wire pipeline.
TS
// src/angular/appProviders.ts
import { provideHttpClient, withFetch } from '@angular/common/http';
import type { EnvironmentProviders, Provider } from '@angular/core';

// Global DI every Angular page on this server gets at SSR + client bootstrap.
// Per-page additions (router, APP_BASE_HREF) are auto-wired by the build,
// so keep this file focused on cross-cutting concerns: http, error
// handlers, interceptors, locale, app-wide services.
export const appProviders: ReadonlyArray<Provider | EnvironmentProviders> = [
  provideHttpClient(withFetch())
];

#Zoneless Change Detection

AbsoluteJS bootstraps every Angular page with provideZonelessChangeDetection(). There is no opt-out: zone.js is never loaded into the client bundle, never patches the browser's async primitives, and never auto-ticks change detection. This produces smaller bundles and aligns with Angular's long-term direction, but it changes what triggers a re-render.

In a zoneless app, change detection runs only in response to explicit triggers. Mutating a plain class property inside an await, setTimeout, RxJS .subscribe callback, or other async source updates the value but does not tell Angular to re-evaluate the template. The most reliable way to make state reactive is to store it in a signal():

TS
// AbsoluteJS bootstraps Angular with provideZonelessChangeDetection().
// Change detection runs ONLY when one of these happens:
//
//   1. A signal you read in a template is updated         (signal.set / .update)
//   2. A template DOM event fires                         ((click), (input), ...)
//   3. AsyncPipe receives an emission                     ({{ obs$ | async }})
//   4. cdr.markForCheck() / cdr.detectChanges() is called (manual escape hatch)
//   5. HttpClient (and a few other built-ins) settle      (PendingTasks tracker)
//
// Plain property assignment in an await/setTimeout/subscribe callback does
// NOT tick CD. The value changes; the template does not re-evaluate.

@Component({ /* ... */ })
export class ProfileComponent {
  // Broken in zoneless mode: setting plainLoading after await never updates UI.
  plainLoading = false;

  // Correct: the signal triggers CD on its consumers automatically.
  loading = signal(false);

  async load() {
    this.loading.set(true);
    await this.profileService.fetch();
    this.loading.set(false);
  }
}

Two-way [(ngModel)] bindings and template event handlers like (click) are handled for you — Angular installs its own listener wrappers that tick CD when those fire. The gotcha-prone surfaces are await/then, raw setTimeout, and observables you subscribe to manually. The composables in the next section cover those.

#Composables

AbsoluteJS ships a small set of zoneless-safe composables from @absolutejs/absolute/angular. They cover the three patterns that most often leak state or fail to re-render in a zoneless app: timers, async data fetching, and Observable subscriptions. Each must be called inside an Angular injection context (component constructor, field initializer, or runInInjectionContext).

usePageContext<T>() — typed accessor for the per-request payload the backend handler passed via requestContext. AbsoluteJS hydrates the value into Angular's standard REQUEST_CONTEXT token on both SSR and client bootstrap, so the object usePageContext() returns is identical across phases. The page declares its own Context type near the component and passes it as the generic argument; the same type parameterises handleAngularPageRequest<Context>() in the backend, so the contract is enforced at the call site and there is no per-page cast.

TS
import { usePageContext } from '@absolutejs/absolute/angular';
import { Component } from '@angular/core';

// Declare the page's context shape next to the component. The backend's
// handleAngularPageRequest<Context>({ requestContext }) call is typechecked
// against this same type, so both sides agree on what's in flight.
export type Context = {
  user: { id: string; name: string; role: 'admin' | 'user' };
};

@Component({
  selector: 'app-dashboard',
  standalone: true,
  template: `<p>Welcome, {{ ctx.user.name }}</p>`
})
export class DashboardComponent {
  // One generic, no `as` cast at the call site. The composable owns the
  // single cast from REQUEST_CONTEXT's `unknown` value type to T.
  readonly ctx = usePageContext<Context>();
}

useTimers() — component-scoped setTimeout / setInterval with automatic cleanup on destroy. Use it instead of raw setTimeout so timers never outlive the component that scheduled them, and pair it with signals if the callback drives the template.

TS
import { useTimers } from '@absolutejs/absolute/angular';
import { Component, signal } from '@angular/core';

@Component({ /* ... */ })
export class FlashMessage {
  private timers = useTimers();
  visible = signal(false);

  show() {
    this.visible.set(true);
    // Auto-cleared on component destroy. Signals tick CD in the callback.
    this.timers.setTimeout(() => this.visible.set(false), 3000);
  }
}

useResource() — signal-backed async fetcher. Returns data, error, and loading signals plus refresh() and mutate() methods. The fetcher receives an AbortSignal that fires on destroy or on a subsequent refresh, so in-flight requests are cancelled deterministically. Reading the returned signals in a template is enough to drive re-renders. Use mutate(value) after an edit action returns the new entity, so you can update the displayed value without a wasteful re-fetch.

TS
import { useResource } from '@absolutejs/absolute/angular';
import { Component, inject } from '@angular/core';
import { ApiClient } from './api';

@Component({
  selector: 'app-profile',
  standalone: true,
  template: `
    @if (profile.loading()) {
      <p>Loading...</p>
    } @else if (profile.error()) {
      <p>Failed to load.</p>
    } @else if (profile.data(); as p) {
      <h1>{{ p.name }}</h1>
      <button (click)="rename(p, 'Renamed')">Rename</button>
    }
    <button (click)="profile.refresh()">Reload</button>
  `,
})
export class ProfileComponent {
  private api = inject(ApiClient);
  // Signal-backed async data. data/error/loading are signals, so reading them
  // in the template auto-ticks CD. The AbortSignal aborts on destroy or refresh.
  profile = useResource((signal) => this.api.profile.get({ signal }));

  async rename(current: Profile, name: string) {
    const updated = await this.api.profile.update({ id: current.id, name });
    // mutate() writes the new value into the resource without a wasteful
    // re-fetch. Pass a value or an updater function.
    this.profile.mutate(updated);
  }
}

If the fetcher depends on state that's set after field initializers run — a typical example is this.id assigned by the page factory from a :id route param — pass { start: 'pending' } and call refresh() from ngOnInit. The available start values are:

'pending'Keeps loading() true on first paint so the template renders the spinner branch immediately, with no blank-frame flash between mount and the first fetch.
'immediate'defaultFire the fetcher at construction.
'idle'Dormant until refresh() or mutate() is called explicitly.
When to reach for TanStack Query instead
useResource is intentionally minimal: each instance owns its own copy of the data. Two components that fetch the same entity will fire two requests, and an edit in one place won't propagate to another unless you wire it through manually. If you need a shared cache, request deduplication, automatic refetch on focus or reconnect, query invalidation by key, optimistic updates with rollback, or paginated/infinite queries, install @tanstack/angular-query alongside this composable. Use useResource for one-off fetches and trivial admin screens; reach for TanStack Query when the data layer is shared across pages or needs cache semantics.

useSubscription() — wraps observable.subscribe(...) with takeUntilDestroyed() so you can't forget the cleanup operator. The most common source of Angular memory leaks collapses into a single call. The observer body still needs signals (or an explicit cdr.markForCheck()) if it mutates state that drives the template — subscription teardown is the only thing this composable handles.

inject(DestroyRef) is only legal in an Angular injection context (constructor, field initializer, runInInjectionContext), so a call from ngOnInit or any other lifecycle hook can't automatically capture the host's DestroyRef. When that happens useSubscription falls back to a plain subscription (the caller owns teardown) and logs a one-time warning. The cleanest fix is to capture DestroyRef once in a field initializer and pass it through:
TS
import { useSubscription } from '@absolutejs/absolute/angular';
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs';

@Component({ /* ... */ })
export class HeaderComponent {
  private router = inject(Router);
  // Capture once in a field initializer — these run inside the
  // injection context. Passing the captured ref into useSubscription
  // is what keeps automatic teardown alive for calls from lifecycle
  // hooks like ngOnInit, where inject(DestroyRef) is illegal.
  private destroyRef = inject(DestroyRef);
  currentPath = signal('');

  ngOnInit() {
    useSubscription(
      this.router.events.pipe(
        filter((event) => event instanceof NavigationEnd),
      ),
      (event) => this.currentPath.set(event.urlAfterRedirects),
      this.destroyRef,
    );
  }
}

#Async Resolvers

Angular router navigation participates in app stability, but custom async work inside a zoneless resolver should be registered with Angular's pending-task tracker. Wrap raw promises, timers, SDK calls, and other non-HttpClient work with withPendingTask so SSR waits before serializing HTML.

TS
import { withPendingTask } from '@absolutejs/absolute/angular';
import type { ResolveFn } from '@angular/router';

type Account = {
  id: string;
  name: string;
};

export const accountResolver: ResolveFn<Account> = () =>
  withPendingTask(async () => {
    // Use this for custom async work that Angular cannot track itself.
    const account = await accountSdk.currentAccount();

    return account;
  });

#Page Handler

Use handleAngularPageRequest from @absolutejs/absolute/angular to render your components. Pass a page importer function, compiled paths, an optional head tag, and optional props:

Framework handlers are non-streaming by default. Add { collectStreamingSlots: true } as the final argument to enable streaming forabs-stream-slot and @defer scenarios.

TS
// backend/server.ts
import { asset, generateHeadElement } from '@absolutejs/absolute';
import { handleAngularPageRequest } from '@absolutejs/absolute/angular';
import type * as DashboardPage from '../angular/Dashboard.server';

new Elysia()
  .get('/dashboard', async ({ cookie }) => {
    const user = await getUser(cookie);

    return handleAngularPageRequest<DashboardPage.Context>({
      pagePath: asset(manifest, 'Dashboard'),
      indexPath: asset(manifest, 'DashboardIndex'),
      headTag: generateHeadElement({
        title: 'Dashboard',
        description: 'User dashboard'
      }),
      requestContext: { user }
    });
  })

#Components

Angular pages in AbsoluteJS are plain standalone components. A page module's default export is the page root; nothing else is required. There is no defineAngularPage wrapper, no exported providers array, and no per-prop InjectionToken ceremony.

Per-request data passed from the backend handler's requestContext argument is read with the usePageContext<T>() composable. The page declares its own Context type next to the component and passes it as the generic; the same type parameterises handleAngularPageRequest<Context>() on the backend, so the contract is enforced at the call site:

TS
// src/angular/Dashboard.server.ts
import { Component } from '@angular/core';
import { usePageContext } from '@absolutejs/absolute/angular';

export type Context = {
  user: {
    name: string;
    email: string;
    role: 'admin' | 'user';
  };
};

@Component({
  selector: 'app-dashboard',
  standalone: true,
  template: `
    <div class="dashboard">
      <h1>Welcome back, {{ ctx.user.name }}</h1>
      <p>Email: {{ ctx.user.email }}</p>
      <p>Role: {{ ctx.user.role }}</p>
    </div>
  `,
  styles: [`
    .dashboard {
      padding: 2rem;
      max-width: 800px;
      margin: 0 auto;
    }
  `]
})
export class Dashboard {
  readonly ctx = usePageContext<Context>();
}

#Provider Model

AbsoluteJS owns the framework providers needed for SSR, hydration, request tokens, sanitization, zoneless change detection, transfer cache, and server-safe animation handling. Application providers come from one place — the angular.providers array in absolute.config.ts. Page modules do not export a providers array of their own.

The build runs an AST scan before any framework compile:

1
Walk the project from your server entrypoint
2
Find every page handler call
handleAngularPageRequest({...})
3
Inject the providers declaration
The providers declaration is injected directly into each page's compiled server output.
TS
export const providers = [
	...appProviders,
	provideRouter(
		routes,
		withComponentInputBinding(),
		withViewTransitions()
	),
	{ provide: APP_BASE_HREF, useValue: "/admin/" }
];
appProvidersThe import resolves to the path the build extracted from absolute.config.ts.
provideRouter(routes, ...)Appended only when the page exports a routes array — see Routing.
APP_BASE_HREFIncluded only when the Elysia mount is a sub-router pattern (.get('/admin/*', ...) '/admin/').

Because the declaration lives in the page module itself, the page's server bundle and the client wrapper both read the same providers export off the same module — one @angular/core instance, no runtime providers indirection.

For request-specific data, inject REQUEST, REQUEST_CONTEXT, or RESPONSE_INIT from your service or resolver, or read the typed payload with usePageContext<T>(). Angular route-level providers inside provideRouter route definitions continue to work normally for token values that should be scoped to one matched route subtree.

Lazy routes using loadComponent also work during SSR, including imports from installed packages. The package must be importable from the server runtime environment; if a package is only available to the browser bundle, SSR cannot resolve that route component.
If an Angular route guard redirects during SSR by returning a UrlTree or redirect command, AbsoluteJS converts that router redirect into an HTTP 302 response with a Location header.
TS
// src/angular/admin/admin.ts
// Page modules are pure Angular. No `export const providers`, no
// `provideRouter(routes)`, no APP_BASE_HREF boilerplate — the build
// appends the providers declaration directly to this module's compiled
// server output:
//
//   export const providers = [
//     ...appProviders,             // from absolute.config.ts > angular.providers
//     provideRouter(routes, ...),  // only when this page exports `routes`
//     { provide: APP_BASE_HREF,    // inferred from the Elysia mount path
//       useValue: '/admin/' }      // e.g. .get('/admin/*', ...) → '/admin/'
//   ];
//
// SSR reads `pageModule.providers` from the bundled page; the client
// wrapper reads the same export off the same module — one
// `@angular/core` instance across both phases.
import { Component } from '@angular/core';
import { RouterOutlet, type Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', component: AdminDashboardComponent },
  { path: 'users', component: AdminUsersComponent }
];

@Component({
  selector: 'admin-page',
  standalone: true,
  imports: [RouterOutlet],
  template: '<router-outlet />'
})
export class AdminComponent {}

#Routing

AbsoluteJS is a multi-page application: each Angular page bootstraps its own application root. A page that wants client-side sub-routes declares them with a top-level export const routes: Routes — the same export name and shape that Angular itself uses in app.routes.ts. The build scans every file under your angularDirectory for this export and, when found, auto-wires provideRouter(routes, withComponentInputBinding(), withViewTransitions()) into the providers literal appended to that page's compiled server output.

TS
// src/angular/portal/portal.ts
import { Component } from '@angular/core';
import { RouterOutlet, type Routes } from '@angular/router';
import { DashboardComponent } from './dashboard/dashboard';
import { SettingsComponent } from './settings/settings';

// Top-level export — same pattern Angular itself uses in app.routes.ts.
// AbsoluteJS detects this export at build time and auto-wires
// provideRouter(routes, withComponentInputBinding(), withViewTransitions())
// into the page's bootstrap providers.
export const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
  { path: 'dashboard', component: DashboardComponent },
  { path: 'settings', component: SettingsComponent }
];

@Component({
  selector: 'portal-page',
  standalone: true,
  imports: [RouterOutlet],
  template: '<router-outlet />'
})
export class PortalComponent {}

APP_BASE_HREF is auto-inferred from the handler call's Elysia mount path at build time. .get('/portal/*', ...) bakes { provide: APP_BASE_HREF, useValue: '/portal/' } into the page's injected providers; root mounts (/, single-segment routes) leave the framework default (/) in place. You never write that provider by hand — renaming a mount in the Elysia chain doesn't require touching the Angular page.

Pages without sub-routes need nothing extra. Omit the routes export and the build skips provideRouter for that page — the injected providers literal is just [...appProviders]:

TS
// src/angular/about/about.ts
// No routes, no providers exports — just a standalone @Component.
// The framework still wires up the global `appProviders` from the config.
import { Component } from '@angular/core';

@Component({
  selector: 'about-page',
  standalone: true,
  template: '<h1>About AbsoluteJS</h1>'
})
export class AboutComponent {}

#Hydration

AbsoluteJS automatically handles Angular SSR and hydration. AOT compilation is used for production builds, while JIT compilation enables fast HMR during development:

TS
// Server-side rendering
// 1. Server renders the component using Angular platform-server
// 2. `requestContext` is serialized into window.__ABS_ANGULAR_REQUEST_CONTEXT__
// 3. Client-side Angular bootstraps and re-provides REQUEST_CONTEXT
//    with the same value, so `usePageContext<T>()` returns identical
//    data on both phases
// 4. The component becomes interactive with all Angular features

// Zoneless by default : no Zone.js needed
// Uses provideZonelessChangeDetection() for optimal performance

// Production: AOT compilation : Angular Linker removes the compiler from bundles
// Development: JIT compilation : faster rebuilds for instant HMR

#HTTP Transfer Cache

Angular's recommended SSR path is to useprovideClientHydration, which includes Angular's built-in HttpClient transfer cache. AbsoluteJS keeps that cache enabled during server rendering and hydration. By default, Angular caches safe HttpClient reads and avoids requests with authorization headers.

Use HttpClient for SSR data reads that should transfer to the browser without a duplicate request. Add x-skip-transfer-cache to individual requests that must always refetch during hydration.

TS
import {
  ABSOLUTE_HTTP_TRANSFER_CACHE_SKIP_HEADER,
  buildAbsoluteHttpTransferCacheOptions
} from '@absolutejs/absolute/angular';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';

// AbsoluteJS applies these defaults automatically during SSR and hydration.
export const providers = [
  provideClientHydration(
    withHttpTransferCacheOptions(buildAbsoluteHttpTransferCacheOptions())
  )
];

// Add the skip header when one HttpClient request should always refetch.
export class AccountService {
  private readonly http = inject(HttpClient);

  account$ = this.http.get('/api/account', {
    headers: {
      [ABSOLUTE_HTTP_TRANSFER_CACHE_SKIP_HEADER]: '1'
    }
  });
}

#View Transitions HMR

CSS-only changes are hot-swapped instantly without touching the app. For template and logic changes, AbsoluteJS uses the browser's native View Transitions API. Instead of the page going blank while the app re-bootstraps with the new module, the browser holds a screenshot and crossfades to the new content once it's ready:

TS
// CSS-only changes: stylesheet is hot-swapped instantly : no re-bootstrap

// Template or logic changes: View Transitions API for zero-flicker updates
// 1. Capture component state via ng.getComponent() + DOM snapshot
// 2. document.startViewTransition() : browser captures a screenshot
// 3. Destroy old app, recreate root element, import updated module
// 4. bootstrapApplication() renders new content behind the screenshot
// 5. Restore state via ng.getComponent() + ng.applyChanges()
// 6. View transition resolves : browser crossfades to new content

// The user never sees empty or default state : only before and after.
// Form inputs, scroll positions, and component state all survive.
// Without View Transitions API support, it falls back gracefully.

#Deterministic Rendering

Angular SSR and hydration must render the same initial values on the server and in the browser. Avoid direct Math.random(), Date.now(), new Date(), crypto.randomUUID(), and performance.now() calls in component field initializers and templates.

Use provideDeterministicEnv for visual variation that must be identical through hydration. The absolute/no-nondeterministic-render ESLint rule catches the common render-time footguns in Angular component fields and inline templates.

TS
import {
  DETERMINISTIC_NOW,
  DETERMINISTIC_RANDOM,
  provideDeterministicEnv
} from '@absolutejs/absolute/angular';
import { Component, inject } from '@angular/core';

@Component({
  providers: [
    provideDeterministicEnv({
      now: '2026-04-29T12:00:00.000Z',
      seed: 'auth-shell-dots'
    })
  ],
  selector: 'auth-shell',
  standalone: true,
  template: `
    @for (dot of dots; track dot.id) {
      <span class="dot" [style.left.%]="dot.x" [style.top.%]="dot.y"></span>
    }
  `
})
export class AuthShell {
  private readonly random = inject(DETERMINISTIC_RANDOM);
  readonly renderedAt = inject(DETERMINISTIC_NOW);

  readonly dots = Array.from({ length: 40 }, (_, id) => ({
    id,
    x: Math.round(this.random() * 100),
    y: Math.round(this.random() * 100)
  }));
}

#SSR Animations

Angular's current recommendation is to use animate.enter and animate.leave for new animation work. These APIs are CSS-class based and fit naturally with SSR.

For existing apps that still use legacy @angular/animations triggers, AbsoluteJS detects those imports and provides Angular's noop animation driver during server rendering. The server output renders the final state instead of trying to run browser animation APIs. After hydration, your client animation providers continue to control browser animations.

#Client Scripts

For adding dynamic client-side behavior after hydration, use the registerClientScript utility:

TS
// For dynamic client-side behavior, use registerClientScript
import { registerClientScript } from '@absolutejs/absolute';

// Register scripts that run after hydration
registerClientScript(() => {
  const button = document.querySelector('.my-button');
  if (button) {
    button.addEventListener('click', () => {
      console.log('Button clicked!');
    });
  }
});

#Multi-Framework

Angular works alongside other frameworks in the same AbsoluteJS application. Each route can use a different framework:

TS
// Use Angular alongside other frameworks
// absolute.config.ts
import { defineConfig } from '@absolutejs/absolute';

export default defineConfig({
  reactDirectory: './src/react',
  angularDirectory: './src/angular',
  vueDirectory: './src/vue'
});

// server.ts: mix and match frameworks per route
import { handleReactPageRequest } from '@absolutejs/absolute/react';
import { handleAngularPageRequest } from '@absolutejs/absolute/angular';
import { handleVuePageRequest } from '@absolutejs/absolute/vue';

new Elysia()
  .use(absolutejs)
  .get('/', () => handleReactPageRequest({ Page: Home, index: asset(manifest, 'HomeIndex') }))
  .get('/admin', () =>
    handleAngularPageRequest<AdminPage.Context>({
      pagePath: asset(manifest, 'Admin'),
      indexPath: asset(manifest, 'AdminIndex'),
      headTag: generateHeadElement({ title: 'Admin Panel' }),
      requestContext: { stats: adminStats }
    })
  )
  .get('/store', () =>
    handleVuePageRequest<typeof Store>({
      pagePath: asset(manifest, 'Store'),
      indexPath: asset(manifest, 'StoreIndex'),
      headTag: generateHeadElement({ title: 'Store' }),
      props: { products }
    })
  )
  • AOT in Production: Angular Linker plugin removes the compiler from browser bundles
  • JIT in Dev: Faster rebuilds during development with HMR support
  • DOM State Preservation: Form inputs and scroll positions are preserved during HMR