+1 (726) 227-3745

Building a Modern MEAN App in 2026: Angular 22 + Express 5 + MongoDB 8 + Node 24

This tutorial builds a small but complete MEAN application the way we build them for clients in 2026: Angular 22 on the front end, an Express 5 API with async handlers, MongoDB 8 through Mongoose 8, Node.js 24 LTS, and JWT authentication. The example is a task list, deliberately boring so the stack is the point. All versions are current as of August 2026.

Prerequisites

  • Node.js 24 LTS (node -v should print v24.x). Use nvm if you need to switch.
  • A MongoDB 8.0 instance: a free Atlas cluster or a local server (docker run -d -p 27017:27017 mongo:8.0).
  • Angular CLI 22: npm install -g @angular/cli@22.

Part 1: the API (Express 5 + Mongoose 8)

mkdir mean-2026 && cd mean-2026
mkdir api && cd api
npm init -y
npm install express@5 mongoose@8 jsonwebtoken argon2 zod cors
npm pkg set type=module

Create api/src/db.js:

import mongoose from 'mongoose';

export async function connectDb(uri = process.env.MONGODB_URI ?? 'mongodb://localhost:27017/mean2026') {
  mongoose.set('strictQuery', true);
  await mongoose.connect(uri);
  return mongoose.connection;
}

api/src/models.js:

import { Schema, model } from 'mongoose';

const userSchema = new Schema({
  email: { type: String, required: true, unique: true, lowercase: true, trim: true },
  passwordHash: { type: String, required: true },
}, { timestamps: true });

const taskSchema = new Schema({
  owner: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
  title: { type: String, required: true, trim: true, maxlength: 200 },
  done: { type: Boolean, default: false },
}, { timestamps: true });

export const User = model('User', userSchema);
export const Task = model('Task', taskSchema);

api/src/auth.js issues and verifies JWTs. Keep the secret in an environment variable; for production use an RS256 key pair.

import jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET ?? 'dev-only-change-me';

export const issueToken = (user) =>
  jwt.sign({ sub: user.id, email: user.email }, SECRET, { algorithm: 'HS256', expiresIn: '15m' });

export function requireAuth(req, res, next) {
  const header = req.get('authorization') ?? '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: 'missing token' });
  try {
    req.user = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
    next();
  } catch {
    res.status(401).json({ error: 'invalid token' });
  }
}

api/src/app.js is where Express 5 earns its keep. Every handler is async, and a thrown error or rejected promise lands in the error middleware without a wrapper:

import express from 'express';
import cors from 'cors';
import argon2 from 'argon2';
import { z } from 'zod';
import { User, Task } from './models.js';
import { issueToken, requireAuth } from './auth.js';

export const app = express();
app.use(cors({ origin: 'http://localhost:4200' }));
app.use(express.json());

const Credentials = z.object({ email: z.string().email(), password: z.string().min(8).max(128) });
const TaskBody = z.object({ title: z.string().min(1).max(200), done: z.boolean().optional() });

app.post('/api/register', async (req, res) => {
  const { email, password } = Credentials.parse(req.body);
  const user = await User.create({ email, passwordHash: await argon2.hash(password) });
  res.status(201).json({ token: issueToken(user) });
});

app.post('/api/login', async (req, res) => {
  const { email, password } = Credentials.parse(req.body);
  const user = await User.findOne({ email });
  if (!user || !(await argon2.verify(user.passwordHash, password))) {
    return res.status(401).json({ error: 'bad credentials' });
  }
  res.json({ token: issueToken(user) });
});

app.get('/api/tasks', requireAuth, async (req, res) => {
  res.json(await Task.find({ owner: req.user.sub }).sort({ createdAt: -1 }).lean());
});

app.post('/api/tasks', requireAuth, async (req, res) => {
  const body = TaskBody.parse(req.body);
  res.status(201).json(await Task.create({ ...body, owner: req.user.sub }));
});

app.patch('/api/tasks/:id', requireAuth, async (req, res) => {
  const body = TaskBody.partial().parse(req.body);
  const task = await Task.findOneAndUpdate({ _id: req.params.id, owner: req.user.sub }, body, { new: true });
  if (!task) return res.status(404).json({ error: 'not found' });
  res.json(task);
});

app.delete('/api/tasks/:id', requireAuth, async (req, res) => {
  const result = await Task.deleteOne({ _id: req.params.id, owner: req.user.sub });
  res.status(result.deletedCount ? 204 : 404).end();
});

// Express 5 routes rejected promises here automatically.
app.use((err, req, res, next) => {
  if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues });
  if (err.code === 11000) return res.status(409).json({ error: 'email already registered' });
  if (err.name === 'CastError') return res.status(404).json({ error: 'not found' });
  console.error(err);
  res.status(500).json({ error: 'internal error' });
});

Note two things. Every task query includes owner: req.user.sub, so a user can never read or modify another user's tasks even with a guessed id. And Credentials.parse rejects { "$ne": "" }-style payloads before they reach MongoDB, which is the primary defense against NoSQL injection.

api/src/server.js:

import { connectDb } from './db.js';
import { app } from './app.js';

await connectDb();
app.listen(3000, () => console.log('API on http://localhost:3000'));

Run it with node src/server.js (Node 24 supports top-level await in ESM). Add "dev": "node --watch src/server.js" to package.json scripts for auto-reload without nodemon.

Part 2: the client (Angular 22)

cd ..
ng new client --style=css --ssr=false --skip-tests
cd client

Angular 22 generates standalone components and signals-based defaults. Start with an auth service that stores the token in a signal and exposes an HttpInterceptorFn to attach it. client/src/app/auth.ts:

import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpInterceptorFn } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';

const API = 'http://localhost:3000/api';

@Injectable({ providedIn: 'root' })
export class Auth {
  private http = inject(HttpClient);
  readonly token = signal<string | null>(localStorage.getItem('token'));
  readonly loggedIn = computed(() => this.token() !== null);

  async login(email: string, password: string, register = false) {
    const path = register ? 'register' : 'login';
    const res = await firstValueFrom(this.http.post<{ token: string }>(`${API}/${path}`, { email, password }));
    localStorage.setItem('token', res.token);
    this.token.set(res.token);
  }

  logout() {
    localStorage.removeItem('token');
    this.token.set(null);
  }
}

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(Auth).token();
  return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};

Register the interceptor in client/src/app/app.config.ts:

import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './auth';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};

Zoneless change detection is stable in Angular 22 and is what we default to on new projects; signals drive rendering, so zone.js is unnecessary. Now the task list component, client/src/app/tasks.ts:

import { Component, inject, signal, computed, effect } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { Auth } from './auth';

interface Task { _id: string; title: string; done: boolean; }
const API = 'http://localhost:3000/api';

@Component({
  selector: 'app-tasks',
  template: `
    @if (auth.loggedIn()) {
      <form (submit)="add($event)">
        <input name="title" [value]="draft()" (input)="draft.set($any($event.target).value)" placeholder="New task" />
        <button type="submit" [disabled]="!draft().trim()">Add</button>
      </form>
      <p>{{ remaining() }} remaining</p>
      <ul>
        @for (task of tasks(); track task._id) {
          <li>
            <label>
              <input type="checkbox" [checked]="task.done" (change)="toggle(task)" />
              {{ task.title }}
            </label>
            <button (click)="remove(task)">Delete</button>
          </li>
        } @empty {
          <li>No tasks yet.</li>
        }
      </ul>
      <button (click)="auth.logout()">Log out</button>
    } @else {
      <form (submit)="login($event)">
        <input name="email" type="email" required />
        <input name="password" type="password" required minlength="8" />
        <button type="submit">Log in</button>
        <button type="button" (click)="login($event, true)">Register</button>
      </form>
    }
  `,
})
export class Tasks {
  auth = inject(Auth);
  private http = inject(HttpClient);
  tasks = signal<Task[]>([]);
  draft = signal('');
  remaining = computed(() => this.tasks().filter(t => !t.done).length);

  constructor() {
    effect(() => { if (this.auth.loggedIn()) this.load(); else this.tasks.set([]); });
  }

  async load() {
    this.tasks.set(await firstValueFrom(this.http.get<Task[]>(`${API}/tasks`)));
  }

  async login(event: Event, register = false) {
    event.preventDefault();
    const form = (event.target as HTMLElement).closest('form') as HTMLFormElement;
    const data = new FormData(form);
    await this.auth.login(String(data.get('email')), String(data.get('password')), register);
  }

  async add(event: Event) {
    event.preventDefault();
    const created = await firstValueFrom(this.http.post<Task>(`${API}/tasks`, { title: this.draft() }));
    this.tasks.update(list => [created, ...list]);
    this.draft.set('');
  }

  async toggle(task: Task) {
    const updated = await firstValueFrom(this.http.patch<Task>(`${API}/tasks/${task._id}`, { done: !task.done }));
    this.tasks.update(list => list.map(t => (t._id === task._id ? updated : t)));
  }

  async remove(task: Task) {
    await firstValueFrom(this.http.delete(`${API}/tasks/${task._id}`));
    this.tasks.update(list => list.filter(t => t._id !== task._id));
  }
}

Replace the generated app.ts template with <app-tasks /> and import Tasks into its imports array. Run ng serve, open http://localhost:4200, register, and add tasks. Every state change flows through a signal; there is no zone.js, no ChangeDetectorRef, and no subscription management.

Part 3: production notes

  • SSR. For a public-facing app, create the project with --ssr (or run ng add @angular/ssr later). Angular 22's incremental hydration lets you defer hydrating below-the-fold components with @defer (hydrate on viewport).
  • Serve the client from Express. Build with ng build, then in the API add app.use(express.static('../client/dist/client/browser')) and a final app.get('/{*splat}', ...) that sends index.html. Note the Express 5 wildcard syntax.
  • Secrets and keys. Switch JWT signing to RS256 with a key pair, load secrets from your platform's secret store, and set cors to your real origin.
  • MongoDB. Enable schema validation on the collections (Mongoose schemas do not protect against writes from other clients), create the owner index explicitly in Atlas, and run on 8.0 with backups configured.
  • Tests. node --test or Vitest for the API with mongodb-memory-server; Playwright for the browser, which we cover in E2E testing Angular with Playwright.

The full pattern, an Express 5 API with schema validation and a signals-first Angular client, is what we mean when we say "modern MEAN." If you have an older application that should look like this and does not, our AngularJS to Angular migration and version upgrade services are built for exactly that.