Keep the browser dependent on a stable API contract rather than importing backend implementation details.
Course navigationPhase 06⌄
Next.js <-> NestJS Communication
Connect Next.js and NestJS through an explicit, debuggable HTTP boundary with environment-safe base URLs and correct CORS behavior.
Phase outcome and project increment
Learning outcome
Connect Next.js and NestJS through an explicit, debuggable HTTP boundary with environment-safe base URLs and correct CORS behavior.
Concrete deliverable
A typed API client boundary that can call the NestJS health/tasks endpoints from the appropriate Next.js execution context.
Principles to keep while coding
Configure CORS narrowly for the origins that should call the API.
Centralize base URLs and environment-specific configuration instead of hard-coding localhost throughout components.
Trace failures across browser network tools, API logs, and server responses rather than guessing from UI symptoms.
Guided phase lab
Do these in order. Each step should end with evidence before the next one starts.
- 01
Define API base URLs per environment instead of hard-coding localhost throughout components.
- 02
Call a public endpoint from a Server Component and inspect where the request originates.
- 03
Call an endpoint from a Client Component and observe browser CORS behavior.
- 04
Configure NestJS CORS for the exact development origin and credentials policy required.
- 05
Centralize response parsing and non-2xx error handling in one API helper.
- 06
Use Network tab plus API logs to trace one failing request end-to-end.
pnpm --filter web dev
pnpm --filter api start:devPredict what each command proves before running it. A command is useful only when its output answers a question.
Failures you should know how to diagnose
Using the browser-only URL from server-to-server code in deployment
Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.
Wildcard CORS with credentials
Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.
Ignoring non-2xx fetch responses because fetch did not throw
Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.
Duplicating fetch/error logic in every component
Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.
Lessons
The source chapters come first. Every phase now ends with a guided build, a debugging workshop, and an independent challenge so reading turns into transferable skill.
From Two Apps to One System
FULL_STACK_DEVELOPER_2026_COMPLETE - Phase 06 From Two Apps to One System VOLUME 02 - FRONTEND + BACKEND FOUNDATIONS From Two Apps to One System Execution environments, network paths and the
API URLs and Environment Boundaries
FULL_STACK_DEVELOPER_2026_COMPLETE - Phase 06 API URLs and Environment Boundaries VOLUME 02 - FRONTEND + BACKEND FOUNDATIONS API URLs and Environment Boundaries Configuration, NEXT_PUBLIC_ a
Server Side Fetch with Next.js
FULL_STACK_DEVELOPER_2026_COMPLETE - Phase 06 Server-Side Fetch with Next.js VOLUME 02 - FRONTEND + BACKEND FOUNDATIONS Server-Side Fetch with Next.js Server Component -> NestJS before brows
Browser Fetch and CORS
FULL_STACK_DEVELOPER_2026_COMPLETE - Phase 06 Browser Fetch and CORS VOLUME 02 - FRONTEND + BACKEND FOUNDATIONS Browser Fetch and CORS Client Component -> NestJS and the browser security bou
CORS Preflight and Fetch Error Semantics
FULL_STACK_DEVELOPER_2026_COMPLETE - Phase 06 CORS Preflight and Fetch Error Semantics VOLUME 02 - FRONTEND + BACKEND FOUNDATIONS CORS Preflight and Fetch Error Semantics OPTIONS, JSON POST,
Clean API Client Boundary
FULL_STACK_DEVELOPER_2026_COMPLETE - Phase 06 A Clean API Client Boundary VOLUME 02 - FRONTEND + BACKEND FOUNDATIONS A Clean API Client Boundary Centralize transport details without prematur
Loading Error Empty and Success States
FULL_STACK_DEVELOPER_2026_COMPLETE - Phase 06 Loading, Error, Empty and Success States VOLUME 02 - FRONTEND + BACKEND FOUNDATIONS Loading, Error, Empty and Success States Make network depend
DevTools and End to End Debugging
FULL_STACK_DEVELOPER_2026_COMPLETE - Phase 06 DevTools and End-to-End Debugging VOLUME 02 - FRONTEND + BACKEND FOUNDATIONS DevTools and End-to-End Debugging Trace browser, Next.js and NestJS
Hands On Lab and Phase Exit
FULL_STACK_DEVELOPER_2026_COMPLETE - Phase 06 Hands-On Lab and Phase Exit VOLUME 02 - FRONTEND + BACKEND FOUNDATIONS Hands-On Lab and Phase Exit Build, break, debug and explain the first com
Guided Build Lab
Purpose Connect Next.js and NestJS through an explicit, debuggable HTTP boundary with environment-safe base URLs and correct CORS behavior.
Debugging Workshop
Goal Learn to diagnose this phase's failures systematically instead of reinstalling packages, changing random configuration, or copying a fix without understanding it.
Phase Synthesis & Independent Challenge
Mission Rebuild the capability of Phase 06 without following the exact code line-by-line. The goal is transfer: can you use the ideas when the variable names, UI, or route shape changes?
Mastery Capstone Lab
Integrate Phase 06 without following a transcript.
Exercises that increase independence
Define API base URLs per environment instead of hard-coding localhost throughout components.
Call an endpoint from a Client Component and observe browser CORS behavior.
reproduce and diagnose — Using the browser-only URL from server-to-server code in deployment.
deliver A typed API client boundary that can call the NestJS health/tasks endpoints from the appropriate Next.js execution context.
Can locate a failure on frontend, network, or API side and Can explain CORS as a browser policy.
Do not leave the phase only because you reached the bottom
Phase exit criteria
Scenario and concept quiz
These questions test engineering choices rather than whether you remember a lesson title.
Reference files
Attempt the work first. Use snapshots to compare a checkpoint, not as a substitute for understanding.
reference/TaskFlow_End_to_End_Snapshot/apps/api/package.json
{
"name": "api",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch"
},
"dependencies": {
"@nestjs/common": "12.0.3",
"@nestjs/core": "12.0.3",
"@nestjs/platform-fastify": "12.0.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "12.0.3",
"@nestjs/schematics": "12.0.3",
"@nestjs/testing": "12.0.3",
"typescript": "~6.0.2"reference/TaskFlow_End_to_End_Snapshot/apps/api/src/app.module.ts
import { Module } from '@nestjs/common';
import { HealthModule } from './health/health.module.js';
import { TodosModule } from './todos/todos.module.js';
@Module({
imports: [HealthModule, TodosModule],
})
export class AppModule {}reference/TaskFlow_End_to_End_Snapshot/apps/api/src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthService } from './health.service.js';
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get()
getHealth() {
return this.healthService.getStatus();
}
}reference/TaskFlow_End_to_End_Snapshot/apps/api/src/health/health.module.ts
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller.js';
import { HealthService } from './health.service.js';
@Module({
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}reference/TaskFlow_End_to_End_Snapshot/apps/api/src/health/health.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class HealthService {
getStatus() {
return {
status: 'ok',
service: 'taskflow-api',
};
}
}reference/TaskFlow_End_to_End_Snapshot/apps/api/src/main.ts
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
app.enableCors({
origin: [
'http://localhost:3000',
'http://127.0.0.1:3000',
],
});
app.enableShutdownHooks();
const port = Number(process.env.PORT ?? 4000);reference/TaskFlow_End_to_End_Snapshot/apps/api/src/todos/todos.controller.ts
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
} from '@nestjs/common';
import { TodosService } from './todos.service.js';
@Controller('todos')
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@Get()
findAll() {
return this.todosService.findAll();reference/TaskFlow_End_to_End_Snapshot/apps/api/src/todos/todos.module.ts
import { Module } from '@nestjs/common';
import { TodosController } from './todos.controller.js';
import { TodosService } from './todos.service.js';
@Module({
controllers: [TodosController],
providers: [TodosService],
})
export class TodosModule {}reference/TaskFlow_End_to_End_Snapshot/apps/api/src/todos/todos.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
interface Todo {
id: number;
title: string;
completed: boolean;
}
@Injectable()
export class TodosService {
private nextId = 3;
private readonly todos: Todo[] = [
{ id: 1, title: 'Learn HTTP', completed: false },
{ id: 2, title: 'Build the API contract', completed: false },
];
findAll() {
return this.todos;
}
reference/TaskFlow_End_to_End_Snapshot/apps/web/.env.example
API_BASE_URL= NEXT_PUBLIC_API_BASE_URL=
reference/TaskFlow_End_to_End_Snapshot/apps/web/package.json
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.3.3",
"react": "19.3.0",
"react-dom": "19.3.0"
}
}reference/TaskFlow_End_to_End_Snapshot/apps/web/src/app/globals.css
:root {
--background: #f6f8fa;
--surface: #ffffff;
--text: #17212b;
--border: #d8e0e6;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--background);
color: var(--text);
font-family: Arial, sans-serif;
}
main {
width: min(1100px, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0;reference/TaskFlow_End_to_End_Snapshot/apps/web/src/app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
export const metadata: Metadata = {
title: "TaskFlow",
description: "Project and task management for focused teams",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body>
<header>
<Link href="/">TaskFlow</Link>
<nav aria-label="Primary navigation">
<Link href="/projects">Projects</Link>
<Link href="/settings">Settings</Link>
</nav>
</header>reference/TaskFlow_End_to_End_Snapshot/apps/web/src/app/page.tsx
export default function HomePage() {
return (
<section>
<p>TaskFlow</p>
<h1>Keep work visible and moving.</h1>
<p>Projects, tasks, ownership, and progress will grow here throughout the course.</p>
</section>
);
}reference/TaskFlow_End_to_End_Snapshot/apps/web/src/app/projects/page.tsx
const projects = [
{ id: 1, name: "Website refresh", status: "Active" },
{ id: 2, name: "Inventory rollout", status: "Planning" },
];
export default function ProjectsPage() {
return (
<section>
<h1>Projects</h1>
{projects.map((project) => (
<article key={project.id}>
<h2>{project.name}</h2>
<p>{project.status}</p>
</article>
))}
</section>
);
}reference/TaskFlow_End_to_End_Snapshot/apps/web/src/app/settings/density-toggle.tsx
"use client";
import { useState } from "react";
export function DensityToggle() {
const [compact, setCompact] = useState(false);
return (
<button onClick={() => setCompact((value) => !value)}>
Density: {compact ? "Compact" : "Comfortable"}
</button>
);
}reference/TaskFlow_End_to_End_Snapshot/apps/web/src/app/settings/page.tsx
import { DensityToggle } from "./density-toggle";
export default function SettingsPage() {
return (
<section>
<h1>Settings</h1>
<DensityToggle />
</section>
);
}reference/TaskFlow_End_to_End_Snapshot/apps/web/src/app/todos/page.tsx
import { fetchTodos } from '@/lib/api/todos';
export default async function TodosPage() {
const apiBaseUrl = process.env.API_BASE_URL;
if (!apiBaseUrl) {
throw new Error('API_BASE_URL is not configured');
}
const todos = await fetchTodos(apiBaseUrl);
return (
<main>
<h1>Todos from NestJS</h1>
{todos.length === 0 ? (
<p>No Todos yet.</p>
) : (
<ul>
{todos.map((todo) => (
<li key={todo.id}>reference/TaskFlow_End_to_End_Snapshot/apps/web/src/app/todos-browser/browser-todos.tsx
'use client';
import { useCallback, useEffect, useState } from 'react';
import { createTodo, fetchTodos, type Todo } from '@/lib/api/todos';
export function BrowserTodos() {
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
const loadTodos = useCallback(async () => {
if (!baseUrl) {
setError('NEXT_PUBLIC_API_BASE_URL is not configured');
setLoading(false);
return;
}
setLoading(true);reference/TaskFlow_End_to_End_Snapshot/apps/web/src/app/todos-browser/page.tsx
import { BrowserTodos } from './browser-todos';
export default function TodosBrowserPage() {
return (
<main>
<h1>Browser-to-API Todos</h1>
<BrowserTodos />
</main>
);
}reference/TaskFlow_End_to_End_Snapshot/apps/web/src/lib/api/todos.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
}
async function expectJson<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${response.url}`);
}
return response.json() as Promise<T>;
}
export async function fetchTodos(baseUrl: string): Promise<Todo[]> {
const response = await fetch(`${baseUrl}/todos`, { cache: 'no-store' });
return expectJson<Todo[]>(response);
}
export async function createTodo(baseUrl: string, title: string): Promise<Todo> {