Angular in Practice #1: Building the Dashboard Skeleton

10 min read

This is the practice series previewed at the end of Advanced #7. You’ve touched every tool from basics through advanced — now we gather those tools in the context of a single product and build a small SaaS, end to end.

What this series will build is an Admin Dashboard. It’s a common but rich domain — auth, CRUD, charts, forms, tables, state management, and deployment all gather in one place. Split into 6 posts.

  • #1 Dashboard skeleton ← this post
  • #2 Authentication flow (login / token / guard / interceptor)
  • #3 Product CRUD (Reactive Forms + HttpClient + Resource API)
  • #4 Orders table and charts
  • #5 State management (signal store → NgRx Signal Store)
  • #6 Testing and deployment

This post is the skeleton. Before drawing a single pixel of UI, we create the project, layer Angular Material, organize folder structure, set the main layout and routing, and put four placeholder stat cards on the first screen.

What the app will look like #

A typical admin shape — top toolbar, left sidebar, main area on the right. Five pages:

RouteScreenSeries appearance
/dashboardStat cards + charts#1 (placeholder) → #4 (charts)
/productsProduct list / create / edit#3
/ordersOrders table#4
/settingsUser / preferences#5
/loginLogin page#2

In this post, we lay down only the routes and empty components. The flesh comes in the next posts.

Creating the project #

We start with ng new, just like in Basics #2. We pin the options up front to skip the interactive prompts.

Create a new project
ng new admin-dashboard --routing --style=scss --ssr=false --standalone
cd admin-dashboard

What the flags mean — --routing pre-creates app.routes.ts, --style=scss for variables and nesting, --ssr=false because this is an admin for closed users so SSR isn’t needed, and --standalone is the default in Angular 17+ but we make it explicit.

Angular Material setup #

For the UI library, we use Angular Material. For an admin, “development speed and consistency” matters more than design itself, so a battle-tested component set fits well.

Install Angular Material
ng add @angular/material

ng add is a different step from npm install — it runs schematics alongside, handling the setup for you. You’ll see three prompts:

  1. Choose a prebuilt theme name, or “custom”: — pick a safe prebuilt theme like Azure/Blue. We’ll switch to custom later in the series.
  2. Set up global Angular Material typography styles?Yes.
  3. Include the Angular animations module?Include and enable animations.

Once installation finishes, the theme import lands in src/styles.scss, and provideAnimations() is added to app.config.ts automatically.

Folder structure #

Sketch the folders first, while the project is still empty. The core / shared / features / layouts four-way split is a standard structure that fits admin apps well.

src/app/
src/app/
├── core/              ← singletons (auth, api, guards, interceptors)
├── shared/            ← stateless reusable pieces (StatCard, dialogs, pipes)
├── features/          ← per-domain page bundles
│   ├── dashboard/  products/  orders/  settings/  auth/
├── layouts/           ← MainLayout, AuthLayout
├── app.component.ts
├── app.config.ts
└── app.routes.ts

The three folders in one line each:

  • core/ — things that “should only be created once.” Auth service, API clients, interceptors
  • shared/stateless pieces used everywhere. Buttons, cards, dialogs, pipes
  • features/ — actual screens. One folder per domain, with components, services, and models inside
Create the folders
mkdir -p src/app/{core/{services,guards,interceptors},shared/components,features/{dashboard,products,orders,settings,auth},layouts/main-layout}
Tip
Carving folders too deep too early is an anti-pattern, but for domains like an admin where what kind of code will go in is predictable, having the slots ready ahead of time means you don’t have to ask “where does this go?” every time you stack code. The reduction in decision fatigue easily outweighs the structural cost.

Building the main layout #

Now MainLayoutComponent, the big frame of the screen. Lay it down once — sidebar + toolbar + main area — and every page swaps inside that frame through router-outlet.

Generate the layout component
ng g c layouts/main-layout --change-detection=OnPush --skip-tests

--change-detection=OnPush bakes in the OnPush we emphasized in Advanced #7 from the start, and --skip-tests skips the spec file for now (we collect tests in #6).

src/app/layouts/main-layout/main-layout.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatListModule } from '@angular/material/list';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';

@Component({
  selector: 'app-main-layout',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [RouterOutlet, RouterLink, RouterLinkActive,
    MatToolbarModule, MatSidenavModule, MatListModule, MatIconModule, MatButtonModule],
  templateUrl: './main-layout.component.html',
  styleUrl: './main-layout.component.scss',
})
export class MainLayoutComponent {
  readonly menu = [
    { label: 'Dashboard', icon: 'dashboard',    link: '/dashboard' },
    { label: 'Products', icon: 'inventory_2',  link: '/products' },
    { label: 'Orders',   icon: 'receipt_long', link: '/orders' },
    { label: 'Settings', icon: 'settings',     link: '/settings' },
  ];
  readonly opened = signal(true);
  toggle() { this.opened.update((v) => !v); }
}

Only modules registered in the imports array are usable in the template. The template splits left/right with mat-sidenav-container, places mat-sidenav (sidebar) on the left and mat-sidenav-content (main area) on the right.

src/app/layouts/main-layout/main-layout.component.html
<mat-toolbar color="primary" class="topbar">
  <button mat-icon-button (click)="toggle()"><mat-icon>menu</mat-icon></button>
  <span class="title">Admin Dashboard</span>
  <span class="spacer"></span>
  <button mat-icon-button><mat-icon>notifications</mat-icon></button>
  <button mat-icon-button><mat-icon>account_circle</mat-icon></button>
</mat-toolbar>

<mat-sidenav-container class="layout">
  <mat-sidenav [opened]="opened()" mode="side" class="sidenav">
    <mat-nav-list>
      @for (item of menu; track item.link) {
        <a mat-list-item [routerLink]="item.link" routerLinkActive="active">
          <mat-icon matListItemIcon>{{ item.icon }}</mat-icon>
          <span matListItemTitle>{{ item.label }}</span>
        </a>
      }
    </mat-nav-list>
  </mat-sidenav>
  <mat-sidenav-content class="content">
    <router-outlet />
  </mat-sidenav-content>
</mat-sidenav-container>

Styles cover only the broad strokes.

src/app/layouts/main-layout/main-layout.component.scss
:host { display: flex; flex-direction: column; height: 100vh; }

.topbar { position: sticky; top: 0; z-index: 10; }
.topbar .title { font-weight: 500; margin-left: 8px; }
.topbar .spacer { flex: 1 1 auto; }

.layout { flex: 1; background: #f5f5f5; }
.sidenav { width: 240px; border-right: 1px solid rgba(0, 0, 0, 0.08); }
.content { padding: 24px; }

a.active { background: rgba(0, 0, 0, 0.04); font-weight: 600; }

@media (max-width: 768px) {
  .sidenav { width: 200px; }
  .content { padding: 16px; }
}

routerLinkActive="active" is the key — the active class is automatically attached to the menu item for the current active route, highlighting it. No “which page am I on?” tracking code needed; the router handles it.

Routing skeleton #

Now lay out the pages in app.routes.ts. Two key ideas — putting the layout as a parent route and separating each feature with lazy loading.

src/app/app.routes.ts
import { Routes } from '@angular/router';
import { MainLayoutComponent } from './layouts/main-layout/main-layout.component';

export const routes: Routes = [
  {
    path: '',
    component: MainLayoutComponent,
    children: [
      { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
      { path: 'dashboard', loadComponent: () => import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent) },
      { path: 'products',  loadChildren: () => import('./features/products/products.routes').then(m => m.PRODUCTS_ROUTES) },
      { path: 'orders',    loadChildren: () => import('./features/orders/orders.routes').then(m => m.ORDERS_ROUTES) },
      { path: 'settings',  loadComponent: () => import('./features/settings/settings.component').then(m => m.SettingsComponent) },
    ],
  },
  { path: 'login', loadComponent: () => import('./features/auth/login.component').then(m => m.LoginComponent) },
  { path: '**', redirectTo: 'dashboard' },
];

Two patterns are being used together here.

  • loadComponent — lazy-load a single component. Fits a single page.
  • loadChildren — lazy-load a bundle of child routes. Fits a domain like Products where list, detail, and create pages travel as a group.

products.routes.ts defines just the child routes, on its own.

src/app/features/products/products.routes.ts
import { Routes } from '@angular/router';

export const PRODUCTS_ROUTES: Routes = [
  { path: '',    loadComponent: () => import('./product-list.component').then(m => m.ProductListComponent) },
  { path: 'new', loadComponent: () => import('./product-form.component').then(m => m.ProductFormComponent) },
  { path: ':id', loadComponent: () => import('./product-detail.component').then(m => m.ProductDetailComponent) },
];

Each feature component is enough as an empty placeholder for this post — slots for the series to fill in one by one.

src/app/features/products/product-list.component.ts (placeholder)
import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-product-list',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<h2>Products</h2><p>The product list will go here (#3).</p>`,
})
export class ProductListComponent {}

Keep the root AppComponent simple — a single <router-outlet /> line.

First screen — Dashboard Home #

The dashboard home gets four stat cards with placeholder data. This is where charts go in #4.

First, pull the stat card out into shared/components — it’s a stateless piece reusable in many places.

src/app/shared/components/stat-card/stat-card.component.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';

@Component({
  selector: 'app-stat-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [MatCardModule, MatIconModule],
  template: `
    <mat-card>
      <mat-card-content>
        <div class="row">
          <mat-icon>{{ icon() }}</mat-icon>
          <div>
            <div class="label">{{ label() }}</div>
            <div class="value">{{ value() }}</div>
          </div>
        </div>
        <div class="delta" [class.up]="delta() >= 0">
          {{ delta() >= 0 ? '▲' : '▼' }} {{ delta() }}%
        </div>
      </mat-card-content>
    </mat-card>
  `,
  styles: [`
    .row { display: flex; gap: 12px; align-items: center; }
    .label { font-size: 12px; color: rgba(0,0,0,0.6); }
    .value { font-size: 24px; font-weight: 600; }
    .delta { margin-top: 8px; font-size: 12px; color: #c62828; }
    .delta.up { color: #2e7d32; }
  `],
})
export class StatCardComponent {
  label = input.required<string>();
  value = input.required<string>();
  icon  = input.required<string>();
  delta = input.required<number>();
}

input.required<...>() is the signal input we saw in Advanced #2. If the parent doesn’t pass a value, it gets caught at compile time.

Now lay four cards in the Dashboard component.

src/app/features/dashboard/dashboard.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { StatCardComponent } from '../../shared/components/stat-card/stat-card.component';

@Component({
  selector: 'app-dashboard',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [StatCardComponent],
  template: `
    <h2>Dashboard</h2>
    <div class="stats">
      <app-stat-card label="Today's Revenue" value="₩1,284,000" icon="payments"     [delta]="12" />
      <app-stat-card label="New Orders"      value="48"          icon="receipt_long" [delta]="-3" />
      <app-stat-card label="Active Users"    value="312"         icon="group"        [delta]="5" />
      <app-stat-card label="Low Stock"       value="7"           icon="warning"      [delta]="-20" />
    </div>
    <section class="placeholder">
      <p>Charts will go here (#4).</p>
    </section>
  `,
  styles: [`
    .stats {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
      gap: 16px;
    }
    .placeholder {
      margin-top: 24px; padding: 48px; background: #fff;
      border-radius: 8px; text-align: center; color: rgba(0,0,0,0.6);
    }
  `],
})
export class DashboardComponent {}

grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)) is the responsive trick — the cards naturally collapse from 4 to 3 to 2 to 1 columns as the screen narrows. A great bang-for-buck grid pattern that works without a single media query.

Sidebar toggle and verifying behavior #

On desktop the sidebar stays open; on mobile it toggles via the hamburger button. The opened signal in MainLayoutComponent plays that role — pressing the button flips opened(), and the [opened]="opened()" binding opens and closes mat-sidenav.

Note
mode="side" pushes the content sideways with the sidebar, while mode="over" floats over the content with a dark backdrop. That’s the behavior commonly seen in mobile hamburger menus — over mode. The standard pattern is to take a BreakpointObserver(['(max-width: 768px)']) as a signal and switch the mode dynamically, but in this post we leave it at simple toggling and revisit it together in #5.

Run ng serve --open and check:

  • Are the icons in the top toolbar and the four menu items in the left sidebar visible?
  • Does pressing the hamburger button open and close the sidebar?
  • On menu click, does the URL change and does the right area’s placeholder swap?
  • At /dashboard, do the four stat cards show up in a grid (and do they collapse as you narrow the window)?
  • In the network tab, do separate chunks load when entering /products (confirming lazy loading)?

That last item is a surprisingly important checkpoint — verifying with your eyes that loadComponent/loadChildren are actually built as separate chunks builds intuition for when we work on bundle size later in the series.

Wrapping up #

In this post, we laid the skeleton of the admin dashboard — ng new for project setup, Angular Material added, the core / shared / features / layouts folder structure, MainLayoutComponent (toolbar + sidenav + router-outlet), lazy routing with loadComponent and loadChildren, and the first screen with four stat cards. This skeleton stays largely unchanged through the rest of the series; starting from the next post, content begins to fill it in.

In the next post, “Angular in Practice #2: Authentication Flow,” we build the /login page, store the JWT token, block unauthenticated access via route guards, and use HTTP interceptors to auto-attach the token and refresh it on expiry — covering the full auth flow at once. It’ll be where the functional guards and interceptor patterns from Advanced #4 come together in the field.

X