Angular & Next.js - Full Stack Development - Part I

Next: Part II - Express, PostgreSQL & Prisma · Part III - Angular, Express & MongoDB

Learning objectives

By the end of this lab you will be able to:

  • create and run an Angular and a Next.js project, and explain their folder structure
  • build pages, a shared layout and navigation (declarative and programmatic) in both frameworks
  • read route parameters and share data between components (parent → child, child → parent)
  • call a REST API and display loading / error states
  • build and validate a form
  • compare the two frameworks (rendering model, state, HTTP, forms)
  • containerize both applications with Docker

Prerequisites: HTML, CSS, JavaScript/TypeScript basics, a terminal, Git, Docker Desktop installed.

Versions used

Versions were the latest stable ones when this lab was written:

Tool Version
Node.js 24 LTS (node -v)
Angular CLI 22 (ng version)
Next.js 16 (React 19)
TypeScript as installed by each project (keep strict enabled)

Expected final layout

Both projects live side by side in one folder (this will become the monorepo of Part II):

fullstack-tp/
├── ngapp/     # Angular   -> http://localhost:4200
└── nxapp/     # Next.js   -> http://localhost:3000

Two different ports: you can run both at the same time.

Project Setup

  • Install Node.js (LTS) and verify:
node -v
npm -v
  • Create the working folder:
mkdir fullstack-tp
cd fullstack-tp
  • Angular project setup (see Angular web site). Answer the CLI questions with the default choices (CSS, no SSR for now):
npx @angular/cli@latest new ngapp
  • Start the Angular project (port 4200):
cd ngapp
ng serve
  • Next.js project setup (see Next.js web site) in the same parent folder (project name nxapp). Accept the defaults: TypeScript, ESLint, Tailwind CSS, src/ directory, App Router:
cd ..
npx create-next-app@latest nxapp
  • Start the Next.js project (port 3000):
cd nxapp
npm run dev

Expected result: the Angular welcome page on localhost:4200 and the Next.js welcome page on localhost:3000.

Understanding project structure

Open the 2 projects in Visual Studio Code, each in its own window.

Item Angular Next.js Role
Scripts & dependencies package.json package.json npm run X runs the scripts section
Locked versions package-lock.json package-lock.json reproducible installs, commit it
Compiler options tsconfig.json tsconfig.json TypeScript target, the strict option
Static files public/ public/ images, favicon, served as is
Build cache .angular/cache .next/ generated, never commit, safe to delete
Dependencies node_modules/ node_modules/ downloaded by npm install, never commit
Your code src/ src/ where you work
Global CSS src/styles.css src/app/globals.css applied to all pages
Config angular.json, src/app/app.config.ts next.config.ts framework configuration

The default page rendered:

  • Next.js: src/app/page.tsx (inside the layout src/app/layout.tsx)
  • Angular: src/app/app.html (the root component App, in app.ts)

Components in both frameworks:

  • In Angular a component is made of 3 files: app.ts (logic), app.html (template) and app.css (styles)
  • In Next.js (React) a component is one function in a .tsx file that returns JSX (HTML + JavaScript expressions). Styling is done with Tailwind classes or a CSS file.

Anatomy of a component: Angular (3 files) vs Next.js (1 function) Anatomy of a component: Angular (3 files) vs Next.js (1 function)

Figure 1 - Anatomy of a component: Angular vs Next.js

Start from a clean page

  • Angular: in src/app/app.html, keep only:
<router-outlet />
  • Next.js: in src/app/page.tsx, keep only:
export default function Home() {
  return <></>;
}
  • Global CSS (applied to all pages): src/styles.css in Angular, src/app/globals.css in Next.js.

Common mistakes: a port already in use (stop the other server, or use ng serve --port 4300 / npm run dev -- -p 3001); forgetting to save a file; editing the wrong project.

Exercise 1: change the page title (<title>): in src/index.html for Angular, in the metadata object of src/app/layout.tsx for Next.js.

Page Routing - Routing from Html file

Angular route table vs Next.js file-system routing Angular route table vs Next.js file-system routing

Figure 2 - Two ways to map a URL to a page

Angular Routing

  • Add a component named header using the command line:
ng g component header

This creates src/app/header/header.ts, header.html, header.css (class Header).

  • To use it, import the class in the imports array of src/app/app.ts and add the tag in app.html before <router-outlet />:
// src/app/app.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Header } from './header/header';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, Header],
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<app-header />

<router-outlet />
  • Change the content of header.html to add a navigation bar:
<nav>
  <a>Home</a>
  <a>Login</a>
  <a>Contact</a>
</nav>
  • Add a CSS style in header.css:
nav {
  display: flex;
  gap: 1rem;
  justify-content: center;
}
  • To navigate from one page to another without reloading the page, use the routerLink directive (import RouterLink in header.ts):
// src/app/header/header.ts
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';

@Component({
  selector: 'app-header',
  imports: [RouterLink],
  templateUrl: './header.html',
  styleUrl: './header.css',
})
export class Header {}
<!-- src/app/header/header.html -->
<nav>
  <a routerLink="/">Home</a>
  <a routerLink="/login">Login</a>
  <a routerLink="/contact">Contact</a>
</nav>
  • Generate the 2 components login and contact:
ng g component login
ng g component contact
  • To activate the routing, modify the routes array in src/app/app.routes.ts:
import { Routes } from '@angular/router';
import { Login } from './login/login';
import { Contact } from './contact/contact';

export const routes: Routes = [
  { path: 'login', component: Login },
  { path: 'contact', component: Contact },
];

The routes are registered in app.config.ts through provideRouter(routes) (already done by the CLI).

Next.js Routing

In Next.js the folder structure is the router: each folder under src/app containing a page.tsx becomes a URL. No route table to write.

  • The layout (src/app/layout.tsx) wraps every page. It is the equivalent of Angular’s app.html + <router-outlet /> ({children} is where the current page is displayed). This is where a shared header belongs.

A shared layout keeps the header and swaps the page A shared layout keeps the header and swaps the page

Figure 3 - The layout stays, the page changes

  • Create a header component. Put reusable components in src/components (not in src/app, to avoid creating routes by mistake). Create src/components/Header.tsx:
import Link from "next/link";

export default function Header() {
  return (
    <nav className="flex justify-center gap-4 p-4">
      <Link href="/">Home</Link>
      <Link href="/login">Login</Link>
      <Link href="/contact">Contact</Link>
    </nav>
  );
}

Link (like routerLink) navigates without a full page reload.

  • Use it in the layout src/app/layout.tsx, so the header appears on every page (keep the fonts / metadata the generator created, only add the marked lines):
import Header from "@/components/Header"; // added

// ...

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>
        <Header /> {/* added */}
        {children}
      </body>
    </html>
  );
}

@/ is an alias for src/ (configured in tsconfig.json).

  • Add the login and contact pages: create the folders login and contact under src/app, each with a page.tsx:

src/app/login/page.tsx:

export default function Login() {
  return <div>Login page</div>;
}

src/app/contact/page.tsx:

export default function Contact() {
  return <div>Contact page</div>;
}

Expected result: on both apps, clicking the menu changes the page and the header stays visible.

Exercise 2: add an About page (/about) and a menu entry in both projects. Then type an unknown URL (/xyz): what do you see in each framework? (Angular: nothing / Next.js: 404 page). Add a wildcard route { path: '**', component: NotFound } in Angular, and a not-found.tsx file in Next.js.

Page Routing - Routing from Typescript file

Angular Routing

  • In app.html, add the 2 buttons:
<app-header />

<button (click)="gotoLogin()">Login Page</button>
<button (click)="gotoContact()">Contact Page</button>

<router-outlet />
  • In app.ts, add the following code in the App class (inject gets the router service, this is dependency injection):
import { Component, inject } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';

export class App {
  private router = inject(Router);

  gotoLogin() {
    this.router.navigate(['/login']);
  }

  gotoContact() {
    this.router.navigate(['/contact']);
  }
}
  • Validate the result in the browser.

Next.js Routing

  • In src/app/page.tsx, add a div with 2 buttons. A component that uses hooks (useRouter) or event handlers must be a Client Component: add "use client" at the very top of the file.
"use client"; // client rendering and interactivity
import { useRouter } from "next/navigation";

export default function Home() {
  const router = useRouter();

  function gotoLogin() {
    router.push("/login");
  }

  function gotoContact() {
    router.push("/contact");
  }

  return (
    <div className="flex gap-2 p-4">
      {/* Tailwind CSS classes style the buttons */}
      <button
        className="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
        onClick={gotoLogin}
      >
        Login Page
      </button>
      <button
        className="rounded bg-green-500 px-4 py-2 font-bold text-white hover:bg-green-700"
        onClick={gotoContact}
      >
        Contact Page
      </button>
    </div>
  );
}

Note: the header comes from the layout, so it is not imported here anymore.

Exercise 3: add a “Back” button that goes to the previous page (Location.back() in Angular, router.back() in Next.js).

Page Routing - Accessing Route params

Angular - Accessing Route params

  • To get parameters from the url, ex: http://localhost:4200/contact/123, add a route with an id parameter in app.routes.ts. Keep the route without parameter as well, so the /contact link keeps working:
{ path: 'contact', component: Contact },
{ path: 'contact/:id', component: Contact },
  • In header.html add a link with an id value:
<a routerLink="/contact/123">Contact 123</a>
  • To show the id in the contact page, use the ActivatedRoute service. In contact.ts:
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

export class Contact {
  id: string | null = null;
  private route = inject(ActivatedRoute);

  constructor() {
    // 'id' is the same name used in the route definition
    this.route.paramMap.subscribe((params) => {
      this.id = params.get('id');
    });
  }
}
  • Display the id with string interpolation in contact.html:
<p>contact works!</p>
Contact Page Id: {{ id }}

Why subscribe? If you navigate from /contact/1 to /contact/2, Angular reuses the component: paramMap is an Observable that emits every time the parameter changes.

Next.js - Accessing Route params

  • A folder whose name is between brackets is a dynamic segment: src/app/contact/[id]/page.tsx handles /contact/123, /contact/abc…

  • Keep src/app/contact/page.tsx for /contact and create src/app/contact/[id]/page.tsx:

"use client";
import { useParams } from "next/navigation";

export default function ContactDetail() {
  const params = useParams<{ id: string }>();

  return (
    <>
      <div>Contact Page</div>
      <h2>Contact ID: {params.id}</h2>
    </>
  );
}
  • In src/app/page.tsx change gotoContact() to add an id value:
function gotoContact() {
  router.push("/contact/123");
}

Server-side alternative (no "use client" needed): in a Server Component the params arrive as a Promise and must be awaited:

export default async function ContactDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <h2>Contact ID: {id}</h2>;
}

Exercise 4: read a query parameter (/contact?ref=home) in both frameworks (ActivatedRoute.queryParamMap / useSearchParams).

Sharing Data - From Parent to Child

Data flows down, events flow up Data flows down, events flow up

Figure 4 - Parent/child communication (this section and the next ones)

Angular - Sharing Data from Parent to Child

  • Create a component named child:
ng g component child
  • Import it in app.ts (imports: [..., Child]), then in app.html add it just before <router-outlet />:
<app-child />
<router-outlet />
  • In app.ts, add a variable in the App class:
export class App {
  content = 'Content from Parent';
}
  • To pass the data to the child component, use a property binding (square brackets) in app.html:
<app-child [content]="content" />
  • In child.ts, receive it with the input() function (signal-based inputs, the modern way):
import { Component, input } from '@angular/core';

export class Child {
  content = input<string>('');
}
  • A signal is read by calling it. In child.html:
<p>Content from parent: {{ content() }}</p>
  • Now pass a list of values. In app.ts add:
export class App {
  states = ['Tozeur', 'Tunis', 'Nabeul'];
}
  • In app.html: <app-child [states]="states" />
  • In child.ts: states = input<string[]>([]);
  • Iterate with the @for control flow in child.html (track helps Angular update only what changed):
@for (state of states(); track state) {
  <div>Name: {{ state }}</div>
} @empty {
  <div>States are empty</div>
}

Angular - Sharing Data from Child to Parent

  • The child sends an event with output(). In child.ts:
import { Component, input, output } from '@angular/core';

export class Child {
  states = input<string[]>([]);
  selected = output<string>();

  select(state: string) {
    this.selected.emit(state);
  }
}
  • In child.html, add a button in the loop:
<button (click)="select(state)">{{ state }}</button>
  • In app.html, listen to the event:
<app-child [states]="states" (selected)="onSelected($event)" />
<p>Selected: {{ chosen }}</p>
  • In app.ts:
chosen = '';
onSelected(state: string) {
  this.chosen = state;
}

Next.js - Sharing Data from Parent to Child

In React data flows down through props. Create src/components/Child.tsx (not in src/app, a page.tsx there would create a route). Note the typing of the props:

type ChildProps = {
  states: string[];
  onSelect: (state: string) => void; // used in the next section
};

export default function Child({ states, onSelect }: ChildProps) {
  return (
    <ul>
      {states.map((state) => (
        <li key={state}>
          <button onClick={() => onSelect(state)}>{state}</button>
        </li>
      ))}
    </ul>
  );
}
  • Use it in the parent src/app/page.tsx. The parent owns the state (useState):
"use client";
import { useState } from "react";
import Child from "@/components/Child";

export default function Home() {
  const [states] = useState(["Tozeur", "Gafsa", "Tunis"]);
  const [chosen, setChosen] = useState("");

  return (
    <>
      {/* ...buttons from the previous section... */}
      <Child states={states} onSelect={setChosen} />
      <p>Selected: {chosen}</p>
    </>
  );
}

Next.js - Sharing Data from Child to Parent

There is no @Output in React: the parent passes a callback function as a prop (onSelect), and the child calls it. That is exactly what the code above does (onSelect={setChosen}).

Angular Next.js / React
Parent → child [prop]="value" + input() <Child prop={value} />
Child → parent (event)="handler($event)" + output() callback prop onEvent={fn}
Local state class property / signal() useState

Exercise 5: add a text input in the parent to add a new state to the list.

API Calls

The public API https://dummyjson.com/products (documentation: dummyjson.com) returns product objects. First define their type: it gives auto-completion and catches typos.

export interface Product {
  id: number;
  title: string;
  description: string;
  price: number;
  thumbnail: string;
  category: string;
}

Put it in src/app/models/product.ts (Angular) and src/types/product.ts (Next.js).

API Calling (Angular)

  • Add a component named products.
  • Enable the HTTP client in src/app/app.config.ts by adding provideHttpClient() in the providers list:
import { provideHttpClient, withFetch } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    // ...existing providers
    provideHttpClient(withFetch()),
  ],
};
  • Good practice: put the HTTP call in a service (a class injectable everywhere), not in the component. Generate it with ng g service product and write src/app/product.ts (or product.service.ts, depending on the generator):
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Product } from './models/product';

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);

  getProduct(id: number): Observable<Product> {
    return this.http.get<Product>(`https://dummyjson.com/products/${id}`);
  }
}
  • In products.ts, use the service (import ProductService from the generated file):
import { Component, inject, signal } from '@angular/core';

export class Products {
  private service = inject(ProductService);

  product = signal<Product | null>(null);
  error = signal('');

  constructor() {
    this.service.getProduct(1).subscribe({
      next: (data) => this.product.set(data),
      error: () => this.error.set('Unable to load the product'),
    });
  }
}
  • In products.html, handle the three states (loading, error, data). Reading product().title before the response arrives would crash, hence the @if:
@if (error()) {
  <p>{{ error() }}</p>
} @else if (product(); as p) {
  <p>{{ p.title }} - {{ p.description }}</p>
} @else {
  <p>Loading product...</p>
}
  • Add <app-products /> to app.html (and to the imports of App) to show it.

API Calling (Next.js)

Next.js offers two ways. Compare them.

1. Client Component (like Angular: fetch in the browser after the page loads). Create src/app/products/page.tsx:

"use client";
import { useEffect, useState } from "react";
import type { Product } from "@/types/product";

export default function Products() {
  const [product, setProduct] = useState<Product | null>(null);
  const [error, setError] = useState("");

  useEffect(() => {
    async function fetchProduct() {
      try {
        const response = await fetch("https://dummyjson.com/products/1");
        if (!response.ok) throw new Error("HTTP " + response.status);
        setProduct(await response.json());
      } catch {
        setError("Unable to load the product");
      }
    }
    fetchProduct();
  }, []); // [] = run once, after the first render

  if (error) return <p>{error}</p>;
  if (!product) return <p>Loading product...</p>;
  return (
    <p>
      {product.title} - {product.description}
    </p>
  );
}

2. Server Component (the default in the App Router: the fetch runs on the server, the browser receives ready-made HTML, no useEffect, no loading state to write). Replace the file above by:

import type { Product } from "@/types/product";

export default async function Products() {
  const response = await fetch("https://dummyjson.com/products/1");
  const product: Product = await response.json();

  return (
    <p>
      {product.title} - {product.description}
    </p>
  );
}

Add a loading.tsx file next to page.tsx (displayed automatically while the server component loads) and an error.tsx file ("use client" required, receives error and reset).

Client Component vs Server Component request flow Client Component vs Server Component request flow

Figure 5 - Where the data is fetched: browser or server

Client Component Server Component
Runs in browser server (then HTML is sent)
Directive "use client" none (default)
Hooks / events (useState, onClick) yes no
async/await directly in component no yes
Secrets (API keys, database) never safe
SEO / first paint worse better

Rule of thumb: stay server by default, switch to client only for interactivity.

  • Add a link to /products in the header to reach the page.

Exercise 6: display the product whose id comes from the URL (/products/[id] in Next.js, /products/:id in Angular). This is the base of the challenge.

Forms

ngModel two-way binding vs React controlled input ngModel two-way binding vs React controlled input

Figure 6 - Form binding in Angular and React

Angular - Forms

  • Import FormsModule in the component that uses it (here contact.ts):
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-contact',
  imports: [FormsModule],
  templateUrl: './contact.html',
  styleUrl: './contact.css',
})
export class Contact { /* ... */ }
  • In contact.html, build the form using ngModel (two-way data binding). Each ngModel field must have a name attribute:
<form #contactForm="ngForm" (ngSubmit)="onSubmit()">
  <input type="text" name="name" [(ngModel)]="formData.name" placeholder="Name" required #name="ngModel" />
  @if (name.invalid && name.touched) {
    <small>Name is required</small>
  }

  <input type="email" name="email" [(ngModel)]="formData.email" placeholder="Email" required email />

  <button type="submit" [disabled]="contactForm.invalid">Submit</button>
</form>
  • In contact.ts, declare formData and handle the submission (keep the id code from the previous section):
formData = {
  name: '',
  email: '',
};

onSubmit() {
  console.log('Form submitted:', this.formData);
}
  • Type in the fields and check the browser console after clicking submit: formData reflects what you typed thanks to [(ngModel)]. The button stays disabled while the form is invalid.

To go further: Reactive Forms (FormGroup, FormControl, Validators) are preferred for larger forms.

Next.js - Forms

  • In React, form fields are controlled components: the value lives in a useState and each input has both value and onChange. No library is needed for a basic form.

  • In src/app/contact/page.tsx (this file needs "use client"):

"use client";
import { useState } from "react";

export default function Contact() {
  const [formData, setFormData] = useState({ name: "", email: "" });

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
  }

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault(); // prevent page reload
    console.log("Form submitted:", formData);
  }

  return (
    <form onSubmit={handleSubmit} className="flex flex-col gap-2 p-4">
      <input type="text" name="name" value={formData.name} onChange={handleChange} placeholder="Name" required />
      <input type="email" name="email" value={formData.email} onChange={handleChange} placeholder="Email" required />
      <button type="submit" disabled={!formData.name || !formData.email}>
        Submit
      </button>
    </form>
  );
}
  • Note the difference with Angular: each input needs both value (from state) and onChange (to update state). The name attribute lets one handler serve all the fields.

To go further: react-hook-form + zod for validation, and Server Actions to submit a form directly to the server.

Exercise 7: show an error message under the email field when it is not a valid email, in both frameworks.

Environment variables

Never hardcode the API URL in components; it changes between development and production.

Angular Next.js
Where src/environments/environment.ts (ng generate environments) .env.local
Example export const environment = { apiUrl: 'https://dummyjson.com' }; NEXT_PUBLIC_API_URL=https://dummyjson.com
Usage environment.apiUrl process.env.NEXT_PUBLIC_API_URL

In Next.js only variables prefixed with NEXT_PUBLIC_ are visible in the browser; the others stay on the server. Add .env.local to .gitignore (already done by create-next-app).

Angular vs Next.js - Summary

Topic Angular Next.js
Type full framework (batteries included) React framework
Language / templates TypeScript + HTML templates TypeScript + JSX
Component class + decorator, 3 files function, 1 file
Routing route table (app.routes.ts) file-system (app/…/page.tsx)
Layout root component + <router-outlet> layout.tsx + {children}
Route params ActivatedRoute useParams() / params prop
State properties, signal() useState
Parent ↔ child input() / output() props / callback props
HTTP HttpClient (Observables), services + DI fetch, Server Components
Forms ngModel, Reactive Forms controlled inputs, Server Actions
Rendering client (SSR optional) server first (SSR / SSG / client)
Styling CSS per component Tailwind / CSS
Dev port 4200 3000

Challenge Activity – Multi-Stack Product Catalogue Development

Objectives

Build a web application that displays a product catalogue using two modern front-end approaches:

  • Angular + Bootstrap
  • Next.js + TailwindCSS

Fetch the data from the public API: https://dummyjson.com/products, then containerize both apps and push the images to Docker Hub.

Requirements

  1. List page /products: a responsive grid of cards showing image, name, price and description (truncated).
  2. Detail page /products/:id (Angular) and /products/[id] (Next.js): all the product information, and a “Back to catalogue” link.
  3. Pagination using the API parameters ?limit=12&skip=0 (Previous / Next buttons).
  4. Loading and error states (a message or spinner, a message if the API fails).
  5. Reusable component for the product card (use input() / props).
  6. Typed code: a Product interface, no any, TypeScript strict enabled.
  7. Angular: use a ProductService. Next.js: use a Server Component for the list, next/image for images (configure images.remotePatterns in next.config.ts for cdn.dummyjson.com).
  8. Bootstrap: npm install bootstrap and add its CSS in angular.json (styles array). Tailwind is already installed in the Next.js project.

Bonus: search box (/products/search?q=), filter by category, favourites or a shopping cart.

Docker

Containerize each application. Expected work (you write the files, look for documentation):

  • a Dockerfile for each app, multi-stage (a Node stage to build, a light final stage to serve)
    • Angular: build with ng build, serve the dist/ with Nginx; the Nginx configuration must redirect unknown paths to index.html (try_files) or refreshing /products/5 gives a 404
    • Next.js: set output: "standalone" in next.config.ts, copy the .next/standalone, .next/static and public folders in the final image, run node server.js
  • a .dockerignore (at least node_modules, .next, dist, .angular, .git)
  • build, run and test locally:
docker build -t <dockerhub-user>/ngapp:1.0 .
docker run -p 8080:80 <dockerhub-user>/ngapp:1.0
  • push the images:
docker login
docker push <dockerhub-user>/ngapp:1.0

Deliverables

  • a Git repository (ngapp/, nxapp/, a README.md explaining how to run each app and how to run the containers)
  • the two Docker Hub image names (public)
  • screenshots of the list and detail pages

Continue with Part II: you will replace dummyjson with your own API.