Tutorials
|stacknotice.com
18 min left|
0%
|3,600 words
Tutorials

The Professional Angular Project Setup in 2026 (Libraries, Architecture & Performance)

The exact stack, folder structure, libraries and configuration a senior Angular developer uses to start a production project in 2026. No filler.

June 1, 202618 min read
Share:
The Professional Angular Project Setup in 2026 (Libraries, Architecture & Performance)

Starting a new Angular project in 2026 means making a lot of decisions upfront. Signals or NgRx? Angular Material or PrimeNG? Jest or the default Karma? Nx monorepo or standard CLI?

This guide covers the exact stack a senior Angular developer uses, with the reasoning behind every choice. Not a tutorial — a decision framework you can apply immediately.

Why Angular in 2026

Before the stack: Angular's position has shifted significantly. Angular 21 removed Zone.js as a requirement, made standalone components the default, and made Signals production-stable. The result is a framework that's genuinely competitive on performance with React and Vue — without the ecosystem fragmentation.

Key facts for 2026:

  • Signals replace Zone.js-based change detection — reactive primitives, zero magic
  • Standalone components are the default — no more NgModule boilerplate
  • esbuild is the default build tool — build times dropped 70%+ vs webpack
  • SSR with hydration is production-stable — Angular Universal merged into the core CLI

If you're evaluating Angular vs React for a new project: Angular wins on team scalability, strict TypeScript integration, and opinionated structure. React wins on ecosystem size and flexibility. For enterprise teams of 5+, Angular is frequently the better long-term choice.

For more on what changed in Angular 21, see our Angular 21 complete guide.


The Full Stack

Angular 21+           — Framework
TypeScript 5.x        — Language (strict mode, always)
Angular Signals       — State management (local/shared)
NgRx SignalStore      — State management (complex/global)
Angular Material 3    — UI components
Tailwind CSS v4       — Utility styling
Angular Router        — Routing (standalone)
Angular HttpClient    — HTTP (interceptors for auth/errors)
Transloco             — i18n
Jest + Testing Library — Unit and component testing
Playwright            — E2E testing
ESLint + angular-eslint — Linting
Prettier              — Formatting
Nx                    — Monorepo (optional, for larger projects)
Docker                — Containerization
GitHub Actions        — CI/CD

Project Scaffolding

# Create new project
ng new my-app \
  --standalone \
  --routing \
  --style=scss \
  --strict
 
cd my-app
 
# Add Angular Material
ng add @angular/material
 
# Add Tailwind
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init
 
# Add ESLint
ng add @angular-eslint/schematics
 
# Add Jest (replace Karma)
npm install -D jest jest-preset-angular @types/jest
npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter
 
# Add Transloco for i18n
ng add @jsverse/transloco
 
# Add NgRx SignalStore (only if you need complex state)
npm install @ngrx/signals

Folder Structure

The single most important architectural decision. Feature-based structure, not type-based.

src/
├── app/
│   ├── core/                    # Singleton services, guards, interceptors
│   │   ├── auth/
│   │   │   ├── auth.guard.ts
│   │   │   ├── auth.interceptor.ts
│   │   │   └── auth.service.ts
│   │   ├── api/
│   │   │   └── api.service.ts
│   │   └── core.providers.ts
│   │
│   ├── shared/                  # Reusable components, pipes, directives
│   │   ├── components/
│   │   │   ├── button/
│   │   │   └── data-table/
│   │   ├── pipes/
│   │   └── directives/
│   │
│   ├── features/                # Feature modules — each self-contained
│   │   ├── dashboard/
│   │   │   ├── components/
│   │   │   ├── services/
│   │   │   ├── store/           # Feature-level state
│   │   │   ├── dashboard.routes.ts
│   │   │   └── dashboard.component.ts
│   │   ├── users/
│   │   └── settings/
│   │
│   ├── app.component.ts
│   ├── app.config.ts            # Application providers
│   └── app.routes.ts
│
├── environments/
│   ├── environment.ts
│   └── environment.prod.ts
└── styles/
    └── global.scss

Why feature-based?

  • Each feature can be lazy-loaded as a unit
  • Teams can own features without touching each other's code
  • Easier to extract features if you ever split into a monorepo

App Configuration (Standalone)

// app/app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'
import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router'
import { provideHttpClient, withInterceptors } from '@angular/common/http'
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'
import { routes } from './app.routes'
import { authInterceptor } from './core/auth/auth.interceptor'
import { errorInterceptor } from './core/api/error.interceptor'
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(
      routes,
      withComponentInputBinding(),   // Route params as @Input()
      withViewTransitions(),         // Native view transitions API
    ),
    provideHttpClient(
      withInterceptors([authInterceptor, errorInterceptor]),
    ),
    provideAnimationsAsync(),
    // provideZoneChangeDetection() — omit if using fully zoneless
  ],
}

State Management: Signals First

The decision tree:

Is the state local to one component?
→ YES: component signal — signal(), computed(), effect()
→ NO: Is it shared between 2-3 sibling components?
  → YES: service with signals
  → NO: Is it complex (pagination, optimistic updates, undo)?
    → YES: NgRx SignalStore
    → NO: service with signals

Local State with Signals

// feature/counter/counter.component.ts
import { Component, signal, computed } from '@angular/core'
 
@Component({
  standalone: true,
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ double() }}</p>
    <button (click)="increment()">+</button>
  `,
})
export class CounterComponent {
  count = signal(0)
  double = computed(() => this.count() * 2)
 
  increment() {
    this.count.update(n => n + 1)
  }
}

Shared State: Service with Signals

// core/auth/auth.service.ts
import { Injectable, signal, computed } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { tap } from 'rxjs/operators'
 
@Injectable({ providedIn: 'root' })
export class AuthService {
  private _user = signal<User | null>(null)
 
  readonly user = this._user.asReadonly()
  readonly isAuthenticated = computed(() => this._user() !== null)
  readonly isAdmin = computed(() => this._user()?.role === 'admin')
 
  constructor(private http: HttpClient) {}
 
  login(credentials: LoginDto) {
    return this.http.post<AuthResponse>('/api/auth/login', credentials).pipe(
      tap(({ user, token }) => {
        localStorage.setItem('token', token)
        this._user.set(user)
      })
    )
  }
 
  logout() {
    localStorage.removeItem('token')
    this._user.set(null)
  }
}

Complex State: NgRx SignalStore

// features/products/store/products.store.ts
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals'
import { inject, computed } from '@angular/core'
import { ProductsService } from '../services/products.service'
import { rxMethod } from '@ngrx/signals/rxjs-interop'
import { pipe, switchMap, tap } from 'rxjs'
 
type ProductsState = {
  products: Product[]
  selectedId: string | null
  loading: boolean
  error: string | null
  filter: string
}
 
const initialState: ProductsState = {
  products: [],
  selectedId: null,
  loading: false,
  error: null,
  filter: '',
}
 
export const ProductsStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(({ products, selectedId, filter }) => ({
    selectedProduct: computed(() =>
      products().find(p => p.id === selectedId()) ?? null
    ),
    filteredProducts: computed(() =>
      products().filter(p =>
        p.name.toLowerCase().includes(filter().toLowerCase())
      )
    ),
    totalCount: computed(() => products().length),
  })),
  withMethods((store, service = inject(ProductsService)) => ({
    loadProducts: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { loading: true, error: null })),
        switchMap(() => service.getAll()),
        tap({
          next: (products) => patchState(store, { products, loading: false }),
          error: (err) => patchState(store, { error: err.message, loading: false }),
        })
      )
    ),
    selectProduct(id: string) {
      patchState(store, { selectedId: id })
    },
    setFilter(filter: string) {
      patchState(store, { filter })
    },
  }))
)

HTTP Layer with Interceptors

// core/auth/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http'
import { inject } from '@angular/core'
import { AuthService } from './auth.service'
 
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = localStorage.getItem('token')
 
  if (!token) return next(req)
 
  return next(req.clone({
    setHeaders: { Authorization: `Bearer ${token}` }
  }))
}
// core/api/error.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'
import { inject } from '@angular/core'
import { Router } from '@angular/router'
import { catchError, throwError } from 'rxjs'
 
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router)
 
  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        localStorage.removeItem('token')
        router.navigate(['/login'])
      }
 
      if (error.status === 403) {
        router.navigate(['/forbidden'])
      }
 
      return throwError(() => error)
    })
  )
}

UI: Angular Material 3 + Tailwind

Use Angular Material for complex interactive components (tables, dialogs, date pickers, forms) and Tailwind for layout and custom styling.

// shared/components/data-table/data-table.component.ts
import { Component, input } from '@angular/core'
import { MatTableModule } from '@angular/material/table'
import { MatPaginatorModule } from '@angular/material/paginator'
import { MatSortModule } from '@angular/material/sort'
 
@Component({
  standalone: true,
  imports: [MatTableModule, MatPaginatorModule, MatSortModule],
  template: `
    <div class="rounded-xl border border-white/10 overflow-hidden">
      <mat-table [dataSource]="data()">
        <ng-content />
      </mat-table>
      <mat-paginator [pageSizeOptions]="[10, 25, 50]" />
    </div>
  `,
})
export class DataTableComponent<T> {
  data = input.required<T[]>()
}

Tailwind config for Angular:

// tailwind.config.js
export default {
  content: ['./src/**/*.{html,ts}'],
  theme: {
    extend: {
      colors: {
        primary: 'var(--mat-primary)',   // Use Material theme tokens
      }
    }
  }
}

Routing with Lazy Loading

Every feature should be lazy-loaded. With Angular 21 standalone, this is one line:

// app/app.routes.ts
import { Routes } from '@angular/router'
import { authGuard } from './core/auth/auth.guard'
 
export const routes: Routes = [
  {
    path: '',
    redirectTo: 'dashboard',
    pathMatch: 'full',
  },
  {
    path: 'login',
    loadComponent: () => import('./features/auth/login/login.component')
      .then(m => m.LoginComponent),
  },
  {
    path: 'dashboard',
    canActivate: [authGuard],
    loadChildren: () => import('./features/dashboard/dashboard.routes')
      .then(m => m.DASHBOARD_ROUTES),
  },
  {
    path: 'products',
    canActivate: [authGuard],
    loadChildren: () => import('./features/products/products.routes')
      .then(m => m.PRODUCTS_ROUTES),
  },
]
// core/auth/auth.guard.ts
import { inject } from '@angular/core'
import { CanActivateFn, Router } from '@angular/router'
import { AuthService } from './auth.service'
 
export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService)
  const router = inject(Router)
 
  if (auth.isAuthenticated()) return true
 
  return router.createUrlTree(['/login'])
}

Testing Setup: Jest + Angular Testing Library

Remove Karma, use Jest — it's 3x faster and has a better developer experience:

// jest.config.js
module.exports = {
  preset: 'jest-preset-angular',
  setupFilesAfterFramework: ['<rootDir>/setup-jest.ts'],
  testPathPattern: '.*\\.spec\\.ts$',
  transform: {
    '^.+\\.(ts|js|html)$': ['jest-preset-angular', {
      tsconfig: '<rootDir>/tsconfig.spec.json',
    }],
  },
  collectCoverageFrom: ['src/**/*.ts', '!src/**/*.spec.ts'],
}
// Example component test
import { render, screen } from '@testing-library/angular'
import { userEvent } from '@testing-library/user-event'
import { ProductCardComponent } from './product-card.component'
 
describe('ProductCardComponent', () => {
  it('emits addToCart event when button clicked', async () => {
    const product = { id: '1', name: 'Widget', price: 29.99 }
    const { fixture } = await render(ProductCardComponent, {
      inputs: { product },
    })
 
    const addSpy = jest.spyOn(fixture.componentInstance.addToCart, 'emit')
    await userEvent.click(screen.getByRole('button', { name: /add to cart/i }))
 
    expect(addSpy).toHaveBeenCalledWith(product.id)
  })
})

Performance Patterns

OnPush Change Detection

Every component should use OnPush — the Angular team recommends it as the default:

@Component({
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `...`,
})
export class ProductListComponent {
  // With Signals, OnPush works automatically
  // The view only updates when signals change
  products = inject(ProductsStore).filteredProducts
}

trackBy for Lists

<!-- Without trackBy: entire list re-renders on any change -->
@for (product of products(); track product.id) {
  <app-product-card [product]="product" />
}

Angular 17+ @for with track is the modern syntax — replaces *ngFor + trackBy.

Preloading Strategy

import { PreloadAllModules } from '@angular/router'
 
provideRouter(
  routes,
  withPreloading(PreloadAllModules), // Preload lazy routes after initial load
)

For large apps, use a custom QuicklinkStrategy from ngx-quicklink — preloads routes the user is likely to visit based on visible links.


ESLint Configuration

// .eslintrc.json
{
  "root": true,
  "ignorePatterns": ["projects/**/*"],
  "overrides": [
    {
      "files": ["*.ts"],
      "extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended",
        "plugin:@angular-eslint/recommended",
        "plugin:@angular-eslint/template/process-inline-templates"
      ],
      "rules": {
        "@angular-eslint/directive-selector": ["error", { "type": "attribute", "prefix": "app", "style": "camelCase" }],
        "@angular-eslint/component-selector": ["error", { "type": "element", "prefix": "app", "style": "kebab-case" }],
        "@typescript-eslint/no-explicit-any": "error",
        "@typescript-eslint/explicit-function-return-type": "warn"
      }
    },
    {
      "files": ["*.html"],
      "extends": ["plugin:@angular-eslint/template/recommended"],
      "rules": {}
    }
  ]
}

Environment Setup

// environments/environment.ts
export const environment = {
  production: false,
  apiUrl: 'http://localhost:3000/api',
  appName: 'MyApp',
}
 
// environments/environment.prod.ts
export const environment = {
  production: true,
  apiUrl: 'https://api.myapp.com',
  appName: 'MyApp',
}
// Usage in services
import { environment } from '../../../environments/environment'
 
@Injectable({ providedIn: 'root' })
export class ApiService {
  private readonly baseUrl = environment.apiUrl
}

Docker for Production

# Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build -- --configuration production
 
FROM nginx:alpine AS runner
COPY --from=builder /app/dist/my-app/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
# nginx.conf — critical for Angular SPA routing
server {
  listen 80;
  root /usr/share/nginx/html;
  index index.html;
 
  location / {
    try_files $uri $uri/ /index.html;  # Always serve index.html for SPA routes
  }
 
  gzip on;
  gzip_types text/css application/javascript application/json;
}

CI/CD with GitHub Actions

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run test -- --coverage --watchAll=false
      - run: npm run build -- --configuration production
 
  docker:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: myapp:latest

What to Avoid

Don't use NgModules for new code. Standalone is the Angular 2026 standard. NgModules still work but they're legacy.

Don't mix Zone.js and zoneless. Pick one. If you're starting fresh, go zoneless — it's the future direction.

Don't overuse NgRx. A service with signals handles 80% of state management needs. NgRx SignalStore only for genuinely complex state with side effects.

Don't skip OnPush. The performance difference is real. Make it a team standard enforced by ESLint.

Don't use any. Enable "noImplicitAny": true and "strict": true in tsconfig. TypeScript's value disappears without it.


Summary: When to Choose What

NeedChoiceWhy
Local component statesignal()Zero overhead, automatic
Shared service stateService + signalsSimple, no boilerplate
Complex global stateNgRx SignalStoreDevTools, time-travel debug
Simple UI componentsTailwindSpeed, no abstraction
Complex UI componentsAngular Material 3Accessibility, consistency
Unit testsJest + ATLSpeed, modern API
E2E testsPlaywrightMulti-browser, reliable
Large team / monorepoNxTask caching, affected commands

This stack is production-proven, aligned with Angular's roadmap, and scales from a solo project to a team of 20+ without architectural rewrites.

For debugging and optimizing this stack, see the companion guide: Angular Debugging: DevTools, Change Detection & AI Workflows.

#angular#typescript#webdev#javascript#tutorials
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.