Angular SPA
This guide uses Angular CLI, Carno controllers, @carno.js/client and @carno.js/static.
Angular CLI owns the development server and HMR. Carno serves the API during development and serves the compiled Angular application in production.
1. Create the projects
Create an Angular application next to the Carno API:
mkdir apps
cd apps
ng new web --routing --style=scss
mkdir api
cd api
bun init
Install the API packages:
bun add @carno.js/core @carno.js/client @carno.js/static
Install the client package in the Angular project:
cd ../web
bun add @carno.js/client
2. Create an API controller
Keep browser-facing endpoints under /api:
// apps/api/src/users/users.controller.ts
import { Controller, Get } from '@carno.js/core';
export interface User {
id: string;
name: string;
}
@Controller('/api/users')
export class UsersController {
@Get()
list(): User[] {
return [
{ id: '1', name: 'Ada' },
{ id: '2', name: 'Grace' },
];
}
}
3. Configure the Carno API
// apps/api/src/main.ts
import path from 'node:path';
import { Carno } from '@carno.js/core';
import { Client } from '@carno.js/client';
import { StaticPlugin } from '@carno.js/static';
import { UsersController } from './users/users.controller';
const production = process.env.NODE_ENV === 'production';
const app = new Carno()
.use(Client({
output: '../web/src/generated/app.ts',
}))
.controllers([UsersController]);
if (production) {
app.use(await StaticPlugin.create({
// Adjust this if angular.json uses dist/web/browser.
root: path.resolve(process.cwd(), '../web/dist/web'),
prefix: '/',
index: 'index.html',
spa: true,
alwaysStatic: true,
cacheControl: 'no-cache',
}));
}
await app.listen(3000);
Angular CLI output paths can vary by Angular version and workspace configuration. Check angular.json and point root at the directory that directly contains index.html.
For example, if the build creates:
apps/web/dist/web/browser/index.html
use:
root: path.resolve(process.cwd(), '../web/dist/web/browser')
If it creates apps/web/dist/index.html, use ../web/dist instead.
4. Configure the Angular development proxy
Create proxy.conf.json in the Angular project:
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
}
}
Start Angular with the proxy:
ng serve --proxy-config proxy.conf.json
The browser uses Angular's origin, usually http://localhost:4200. Requests to /api/* are forwarded to Carno. Carno does not need to serve Angular's development files.
5. Generate the API contract
Angular CLI does not use Vite, so it cannot use carnoClient() directly. The easiest option is to let the Carno API generate the file:
// apps/api/src/main.ts
const app = new Carno()
.use(Client({
output: '../web/src/generated/app.ts',
}))
.controllers([UsersController]);
Start Carno before Angular:
# terminal 1
cd apps/api
bun run src/main.ts
# terminal 2
cd apps/web
ng serve --proxy-config proxy.conf.json
While Carno is running in development, Client() watches the API sources and refreshes src/generated/app.ts when controllers or DTOs change.
If the frontend must start without the API process, generate the file as a separate build step:
bunx carno-client generate \
--root ../api \
--output ../web/src/generated/app.ts
Run that command from apps/web, or use an absolute output path. The output is resolved relative to --root, so ../web/src/generated/app.ts points back into the Angular project. The generated file belongs to the Angular project because Angular's TypeScript compiler must be able to import it.
6. Create the typed client service
Use the browser HTTP entry. Do not import Client() or the code generator into Angular code.
// apps/web/src/app/api.ts
import { Injectable } from '@angular/core';
import { client } from '@carno.js/client/http';
import type { App } from '../generated/app';
const api = client<App>('');
@Injectable({ providedIn: 'root' })
export class ApiService {
async listUsers() {
const result = await api.api.users.get();
if (result.error) {
throw new Error(result.error.value.message);
}
return result.data;
}
}
The empty base URL keeps the client same-origin. Angular's dev proxy handles /api during development, and Carno serves /api in production.
7. Use Signals for reactive state
Angular Signals provide a small reactive state layer for the page:
// apps/web/src/app/users/users.component.ts
import { Component, inject, signal } from '@angular/core';
import type { User } from '../../generated/app';
import { ApiService } from '../api';
@Component({
selector: 'app-users',
standalone: true,
template: `
@if (loading()) {
<p>Loading...</p>
} @else if (error()) {
<p role="alert">{{ error() }}</p>
} @else {
<ul>
@for (user of users(); track user.id) {
<li>{{ user.name }}</li>
}
</ul>
}
`,
})
export class UsersComponent {
private readonly api = inject(ApiService);
readonly users = signal<User[]>([]);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
constructor() {
void this.load();
}
private async load(): Promise<void> {
try {
this.users.set(await this.api.listUsers());
} catch (error) {
this.error.set(error instanceof Error ? error.message : 'Request failed');
} finally {
this.loading.set(false);
}
}
}
The UI updates because the component reads Signals and ApiService changes their values after the HTTP response. @carno.js/client remains responsible only for the typed request.
The same service can instead expose RxJS Observables if that fits the application better.
8. Configure Angular routing
Use Angular Router for browser routes:
// apps/web/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { UsersComponent } from './users/users.component';
export const routes: Routes = [
{ path: 'users', component: UsersComponent },
];
The production server must return index.html for /users, which is why the Carno static configuration uses spa: true.
9. Build and serve in production
Build Angular:
cd apps/web
ng build
Check the generated directory in dist and update the Carno root if necessary. Then start Carno in production mode:
cd apps/api
NODE_ENV=production bun run src/main.ts
On Windows PowerShell:
$env:NODE_ENV = 'production'
bun run src/main.ts
Open http://localhost:3000. Carno serves both Angular's files and the /api controllers.
10. Angular-specific checks
Verify the following:
proxy.conf.jsonforwards/apiwithout removing the prefix.@Controller('/api/...')matches the proxy path.rootpoints directly at the directory containingindex.html.GET /usersafter a hard refresh returns Angular'sindex.html.GET /api/usersreturns JSON.- The generated file is inside the Angular project or is included by its
tsconfig. - Browser code imports
clientfrom@carno.js/client/http, notClient().
Angular development commands
A typical Angular project can use:
{
"scripts": {
"start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build"
}
}
The Angular dev server is used in development. StaticPlugin is used when the compiled Angular output is ready for production.