Analog.js: Angular Full-Stack Meta-Framework Guide
Welcome to the world of Analog.js, the Angular‑centric full‑stack meta‑framework that aims to eliminate the friction between front‑end and back‑end development. If you’ve ever juggled separate Angular, NestJS, and database projects, you’ll appreciate how Analog.js stitches them together under a single, opinionated roof. In this guide we’ll walk through the core concepts, set up a development environment, and build two practical applications that showcase real‑world scenarios—from a classic CRUD dashboard to a live chat powered by WebSockets.
What Makes Analog.js Different?
At its heart, Analog.js is a meta‑framework: it generates boilerplate, enforces conventions, and provides a unified CLI that talks to both Angular and NestJS under the hood. Unlike traditional monorepos where you manually sync TypeScript configurations, Analog.js abstracts that away, letting you focus on business logic.
Key differentiators include:
- Zero‑config TypeScript sharing between client and server.
- Built‑in API routing that mirrors Angular’s router syntax.
- Automatic Prisma integration for database access.
- Server‑side rendering (SSR) support out of the box.
Because Analog.js leans heavily on Angular’s dependency injection and module system, you’ll feel right at home if you’ve already mastered Angular services, guards, and interceptors.
Getting Started: Installation & Project Scaffold
First, install the global CLI. It bundles Node.js, Angular, NestJS, and Prisma, ensuring version compatibility.
npm i -g analog-cli
Next, scaffold a new project. The CLI prompts you for a project name, database type, and whether you want SSR enabled.
analog new my-analog-app
cd my-analog-app
analog serve
The analog serve command launches both the Angular dev server (on localhost:4200) and the NestJS API server (on localhost:3000) with hot‑module replacement for both sides. Open your browser and you’ll see a starter page that confirms the two runtimes are talking.
Understanding the File Structure
Analog.js organizes code into three high‑level folders:
client/– Standard Angular workspace (components, services, modules).server/– NestJS application (controllers, providers, modules).shared/– TypeScript models, DTOs, and utility functions that are compiled into both client and server bundles.
This shared layer is where you define entities that Prisma will map to your database, as well as validation schemas used by Angular forms and NestJS pipes alike.
Example 1: Building a CRUD Dashboard
Let’s create a simple task manager where users can create, read, update, and delete tasks. The UI will be a classic Angular Material table, while the API will expose REST endpoints backed by Prisma.
Step 1: Define the Data Model
In shared/prisma/schema.prisma add a Task model.
model Task {
id Int @id @default(autoincrement())
title String
description String?
completed Boolean @default(false)
createdAt DateTime @default(now())
}
Run the migration to push the schema to your SQLite dev database.
npx prisma migrate dev --name init
Step 2: Create the Server‑Side Service
Inside server/src/tasks/tasks.service.ts we’ll inject Prisma’s client and expose CRUD methods.
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateTaskDto, UpdateTaskDto } from '../../shared/dto/task.dto';
@Injectable()
export class TasksService {
constructor(private prisma: PrismaService) {}
create(dto: CreateTaskDto) {
return this.prisma.task.create({ data: dto });
}
findAll() {
return this.prisma.task.findMany({ orderBy: { createdAt: 'desc' } });
}
update(id: number, dto: UpdateTaskDto) {
return this.prisma.task.update({ where: { id }, data: dto });
}
remove(id: number) {
return this.prisma.task.delete({ where: { id } });
}
}
Step 3: Expose a REST Controller
Analog.js automatically registers controllers that follow the @Controller('tasks') convention.
import { Controller, Get, Post, Body, Param, Patch, Delete } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { CreateTaskDto, UpdateTaskDto } from '../../shared/dto/task.dto';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Post()
create(@Body() dto: CreateTaskDto) {
return this.tasksService.create(dto);
}
@Get()
findAll() {
return this.tasksService.findAll();
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateTaskDto) {
return this.tasksService.update(+id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.tasksService.remove(+id);
}
}
Step 4: Consume the API from Angular
Generate a service in the client workspace using the Angular CLI.
ng generate service services/task
Inject the generated HttpClient and point it to the server’s base URL (Analog.js configures a proxy for you).
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Task } from '../../shared/models/task.model';
@Injectable({ providedIn: 'root' })
export class TaskService {
private readonly api = '/api/tasks';
constructor(private http: HttpClient) {}
getAll(): Observable<Task[]> {
return this.http.get<Task[]>(this.api);
}
create(task: Partial<Task>) {
return this.http.post(this.api, task);
}
update(id: number, changes: Partial<Task>) {
return this.http.patch(`${this.api}/${id}`, changes);
}
delete(id: number) {
return this.http.delete(`${this.api}/${id}`);
}
}
Step 5: Wire Up the UI
Using Angular Material’s mat-table, bind the service data to a paginated grid. The component handles inline editing via a reactive form.
import { Component, OnInit } from '@angular/core';
import { TaskService } from '../../services/task.service';
import { Task } from '../../../shared/models/task.model';
import { FormBuilder, FormGroup } from '@angular/forms';
@Component({
selector: 'app-task-dashboard',
templateUrl: './task-dashboard.component.html',
styleUrls: ['./task-dashboard.component.scss']
})
export class TaskDashboardComponent implements OnInit {
tasks: Task[] = [];
editForm: FormGroup;
constructor(private taskService: TaskService, private fb: FormBuilder) {
this.editForm = this.fb.group({
title: [''],
description: [''],
completed: [false]
});
}
ngOnInit() {
this.loadTasks();
}
loadTasks() {
this.taskService.getAll().subscribe(data => (this.tasks = data));
}
save(task: Task) {
const changes = this.editForm.value;
this.taskService.update(task.id, changes).subscribe(() => this.loadTasks());
}
remove(task: Task) {
this.taskService.delete(task.id).subscribe(() => this.loadTasks());
}
}
The UI is now fully functional: create a new task, edit inline, toggle completion, and delete. All operations hit the same shared DTO definitions, guaranteeing type safety from front‑end to database.
Pro tip: Enable analog generate docs after defining DTOs. Analog.js will auto‑generate OpenAPI specs, which you can import into Postman for quick API testing.
Example 2: Real‑Time Chat with WebSockets
Next, we’ll build a lightweight chat room that demonstrates Analog.js’s built‑in WebSocket gateway. This use case mirrors a typical SaaS feature where low‑latency communication is a must.
Server‑Side Gateway
In server/src/chat/chat.gateway.ts we create a NestJS gateway that broadcasts messages to all connected clients.
import {
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
MessageBody,
ConnectedSocket
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { CreateMessageDto } from '../../shared/dto/message.dto';
@WebSocketGateway({ cors: true })
export class ChatGateway {
@WebSocketServer()
server: Server;
@SubscribeMessage('msgToServer')
handleMessage(@MessageBody() data: CreateMessageDto, @ConnectedSocket() client: Socket) {
// Echo the message to all clients, including the sender
this.server.emit('msgToClient', { ...data, timestamp: new Date() });
}
}
Client Integration
Analog.js bundles socket.io-client for you. In the Angular app, create a chat service that wraps the socket connection.
import { Injectable } from '@angular/core';
import { io, Socket } from 'socket.io-client';
import { Observable } from 'rxjs';
import { Message } from '../../shared/models/message.model';
@Injectable({ providedIn: 'root' })
export class ChatService {
private socket: Socket;
constructor() {
this.socket = io('http://localhost:3000'); // server URL
}
sendMessage(msg: Partial<Message>) {
this.socket.emit('msgToServer', msg);
}
onMessage(): Observable<Message> {
return new Observable(observer => {
this.socket.on('msgToClient', (data: Message) => observer.next(data));
});
}
}
Chat Component UI
The component subscribes to the message stream and updates a scrolling list. It also includes a simple input box bound to a reactive form.
import { Component, OnInit } from '@angular/core';
import { ChatService } from '../../services/chat.service';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Message } from '../../../shared/models/message.model';
@Component({
selector: 'app-chat-room',
template: `
{{msg.author}}: {{msg.content}} {{msg.timestamp | date:'shortTime'}}
`,
styleUrls: ['./chat-room.component.scss']
})
export class ChatRoomComponent implements OnInit {
messages: Message[] = [];
msgForm: FormGroup;
constructor(private chat: ChatService, private fb: FormBuilder) {
this.msgForm = this.fb.group({ content: [''] });
}
ngOnInit() {
this.chat.onMessage().subscribe(msg => this.messages.push(msg));
}
send() {
const content = this.msgForm.value.content.trim();
if (!content) return;
this.chat.sendMessage({ author: 'Me', content });
this.msgForm.reset();
}
}
Run analog serve and open two browser tabs. Typing in one instantly appears in the other—no page reload required.
Pro tip: For production, switch the gateway to use Redis Pub/Sub (set adapter: 'redis') to scale across multiple server instances without losing real‑time sync.
Deep Dive: Prisma Integration & Shared Types
Analog.js treats Prisma as a first‑class citizen. The prisma.service.ts lives in server/src/prisma and is automatically injected wherever you need database access. Because the shared/ folder contains the generated TypeScript types from Prisma, you can import Task or Message directly into Angular components, eliminating duplicate interface definitions.
Example of importing a generated type:
import { Task } from '../../shared/prisma/client'; // auto‑generated by Prisma
This single source of truth reduces runtime errors and keeps API contracts stable across releases.
Server‑Side Rendering (SSR) with Angular Universal
If SEO or initial load performance matters, enable SSR during the project scaffold (the CLI asks “Enable Angular Universal?”). Analog.js then creates a server/main.ts entry point that boots the Angular app on the server side before sending HTML to the client.
To test SSR locally, run:
analog build --ssr
analog serve --ssr
Navigate to http://localhost:4200 and view the page source—you’ll see fully rendered markup, not just a <app-root> placeholder.
Testing Strategy: Unit, Integration, and E2E
Analog.js ships with Jest for both server and client tests, plus Cypress for end‑to‑end scenarios. Because the shared folder is compiled into both runtimes, you can write a single test suite for DTO validation that runs on Node and in the browser.
Unit Test Example (Server)
import { Test, TestingModule } from '@nestjs/testing';
import { TasksService } from './tasks.service';
import { PrismaService } from '../prisma/prisma.service';
describe('TasksService', () => {
let service: TasksService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [TasksService, PrismaService],
}).compile();
service = module.get<TasksService>(TasksService);
});
it('should create a task', async () => {
const dto = { title: 'Test', description: 'Demo' };
const task = await service.create(dto);
expect(task.id).toBeDefined();
expect(task.title).toBe('Test');
});
});
E2E Test with Cypress
This test logs into the dashboard, creates a task, and verifies its appearance.
describe('Task Dashboard', () => {
it('creates a new task', () => {
cy.visit('/');
cy.get('button[aria-label="Add Task"]').click();
cy.get('input[name="title"]').type('Cypress Task');
cy.get('textarea[name="description"]').type('Created by Cypress');
cy.get('button[type="submit"]').click();
cy.contains('Cypress Task').should('exist');
});
});
Deployment Best Practices
Analog.js produces two distinct bundles: dist/client