Full-Stack Practice Lab — React, Next.js, TypeScript, Backend, and DevOps

Full-Stack Practice Lab — React, Next.js, TypeScript, Backend, and DevOps

This is a deeper companion to the study guides. It teaches one practical full-stack shape; it is not a claim about the videos.

The backend guides use Fastify because it keeps the HTTP layer easy to see. That is a teaching choice, not a rule. The same ideas transfer to Next.js route handlers, Express, NestJS, Hono, Go, Java, Python, .NET, or another stack:

React/Next.js UI -> typed request contract -> API/use case -> data store
                 -> auth boundary          -> cache/queue/search when needed
                 -> tests                 -> logs/metrics/traces -> deployment

The technologies change. The responsibilities do not.

1. Build one realistic system

Use a small project-management application:

  • A user signs in and belongs to an organization.
  • A user can list and create projects in only their organization.
  • The Next.js app renders the UI.
  • The API validates input and applies authorization.
  • PostgreSQL stores durable data.
  • Redis is added for caching/queues only after the basic path works.
  • Docker runs local dependencies.
  • Tests and telemetry make it safe to change.

2. A practical workspace

full-stack-lab/
  apps/
    web/                         Next.js + React UI
      app/
        projects/
          page.tsx               server-rendered list page
          new/
            page.tsx             page that renders the client form
      components/
        create-project-form.tsx
    api/                         Fastify API
      src/
        server.ts
        routes/projects.ts
        services/create-project.ts
        repositories/project-repository.ts
  packages/
    contracts/
      src/project.ts             shared request/response shapes
  infra/
    docker-compose.yml
  package.json

You do not need a monorepo on day one. Use this layout when a frontend and API need to share a contract without copying types by hand.

3. Put the request contract in one place

Create packages/contracts/src/project.ts:

import { z } from "zod";

export const createProjectSchema = z.object({
  name: z.string().trim().min(1).max(120),
});

export type CreateProjectInput = z.infer<typeof createProjectSchema>;

export type ProjectResponse = {
  id: string;
  name: string;
  organizationId: string;
  createdAt: string;
};

Code walkthrough

  • import { z } from zod imports a runtime validator. TypeScript types disappear when JavaScript runs; Zod still checks a real HTTP body at runtime.
  • createProjectSchema is the server contract: a name must be text, whitespace is removed, and a name cannot be empty or enormous.
  • z.infer creates a TypeScript type from that same schema. This prevents the type and validator from drifting apart.
  • ProjectResponse is deliberately smaller than a database row. Do not send internal columns simply because they exist in PostgreSQL.

The frontend may use CreateProjectInput for editor autocomplete, but the API must still call createProjectSchema.parse or safeParse. A browser is not a trust boundary.

4. The API owns validation, authorization, and durable state

Create the use case in apps/api/src/services/create-project.ts:

import { randomUUID } from "node:crypto";
import type { CreateProjectInput, ProjectResponse } from "@lab/contracts";

type Actor = {
  userId: string;
  organizationId: string;
};

type ProjectRepository = {
  create(input: {
    id: string;
    name: string;
    organizationId: string;
  }): Promise<ProjectResponse>;
};

export function buildCreateProject(repository: ProjectRepository) {
  return async function createProject(
    actor: Actor,
    input: CreateProjectInput,
  ): Promise<ProjectResponse> {
    return repository.create({
      id: randomUUID(),
      name: input.name,
      organizationId: actor.organizationId,
    });
  };
}

Code walkthrough

  • Actor is a trusted identity created by authentication middleware, not a value taken from the request body.
  • ProjectRepository is an interface. The service does not need to know whether data comes from PostgreSQL, a test fake, or another storage adapter.
  • buildCreateProject receives dependencies once and returns the use-case function. This makes tests small and avoids global database clients.
  • randomUUID creates a server-side id. The client must not choose identifiers for records it does not own.
  • organizationId comes from actor, not input. That prevents a caller from creating a project under another organization by editing JSON in browser developer tools.

Expose it from apps/api/src/routes/projects.ts:

import type { FastifyPluginAsync } from "fastify";
import { createProjectSchema } from "@lab/contracts";

export const projectRoutes: FastifyPluginAsync = async (app) => {
  app.post("/", async (request, reply) => {
    const input = createProjectSchema.parse(request.body);
    const actor = request.currentUser;

    if (!actor) {
      return reply.code(401).send({
        code: "UNAUTHENTICATED",
      });
    }

    const project = await app.createProject(actor, input);

    return reply.code(201).send(project);
  });
};

Route walkthrough

  • The route converts untrusted HTTP JSON into input once.
  • The route gets actor from request.currentUser, which authentication middleware has verified.
  • 401 means no usable identity was provided. A logged-in user who lacks permission would generally receive 403 or a resource-safe 404 depending on your policy.
  • The service performs the business action. The route does not write SQL itself.
  • 201 means a resource was created. The response is a deliberate public shape.

5. The React/Next.js form is an API client, not an authority

Create apps/web/components/create-project-form.tsx:

"use client";

import { useState } from "react";

type FormState = {
  name: string;
  error: string | null;
  saving: boolean;
};

export function CreateProjectForm() {
  const [state, setState] = useState<FormState>({
    name: "",
    error: null,
    saving: false,
  });

  async function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setState((current) => ({ ...current, saving: true, error: null }));

    const response = await fetch("/api/projects", {
      method: "POST",
      headers: {
        "content-type": "application/json",
      },
      body: JSON.stringify({
        name: state.name,
      }),
    });

    if (!response.ok) {
      setState((current) => ({
        ...current,
        saving: false,
        error: "Could not create the project.",
      }));
      return;
    }

    setState({
      name: "",
      error: null,
      saving: false,
    });
  }

  return (
    <form onSubmit={submit}>
      <label>
        Project name
        <input
          value={state.name}
          onChange={(event) =>
            setState((current) => ({
              ...current,
              name: event.target.value,
            }))
          }
        />
      </label>
      {state.error ? <p role="alert">{state.error}</p> : null}
      <button disabled={state.saving}>
        {state.saving ? "Creating..." : "Create project"}
      </button>
    </form>
  );
}

Component walkthrough

  • "use client" is required because this component uses React state and browser events. It does not make the whole Next.js application client-rendered.
  • state is one object so the form’s value, error, and pending state update together predictably.
  • preventDefault stops the browser’s normal full-page form submission.
  • fetch sends JSON to the API. The frontend chooses what to display; it does not decide whether the user may create a project.
  • response.ok handles both validation and authorization failures as a failed UI action. A production app should map stable API error codes to helpful messages.
  • disabling the button prevents an accidental double click, but the backend must still handle retries/idempotency because client-side protection is not enough.

6. Next.js routing choices

You can use either arrangement:

Browser -> Next.js -> Fastify API -> PostgreSQL
Browser ---------------------------> Fastify API

Use a Next.js route handler/proxy when it simplifies cookies, server-side rendering, or one public origin. Use a separate API directly when mobile clients, other services, or a clear backend boundary require it. Both still need an explicit contract, authentication, authorization, validation, timeouts, and observability.

7. Run local infrastructure with Docker Compose

Create infra/docker-compose.yml:

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: practice
      POSTGRES_PASSWORD: practice
      POSTGRES_DB: practice
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7
    ports:
      - "6379:6379"

volumes:
  postgres_data:

Infrastructure walkthrough

  • postgres and redis are local development dependencies, not copies of your application.
  • environment values are deliberately weak local-only credentials. Put real values in a secret manager, never committed Compose files.
  • the port mapping lets your API running on the host connect to localhost.
  • the named volume preserves database data across container restarts. Remove it when you intentionally want a clean learning database.

Run it:

docker compose -f infra/docker-compose.yml up -d
docker compose -f infra/docker-compose.yml ps

8. Test the boundary before the browser

An API test exercises the contract quickly:

import { test } from "node:test";
import assert from "node:assert/strict";
import { app } from "../src/server.js";

test("creates a project for the authenticated organization", async () => {
  const response = await app.inject({
    method: "POST",
    url: "/projects",
    headers: {
      "content-type": "application/json",
      "x-practice-role": "admin",
    },
    payload: {
      name: "Platform",
    },
  });

  assert.equal(response.statusCode, 201);
  assert.equal(response.json().name, "Platform");
});

The important test is not only “does the route return 201?” Add one proving that the created record has the authenticated organization id, not an organization id supplied by the client.

9. What DevOps means in this project

DevOps is not “knowing Docker commands.” It is making the system easy and safe to build, run, observe, deploy, and recover.

commit -> typecheck/test -> build image -> run migration safely
       -> deploy -> readiness check -> monitor -> rollback/forward fix

As the guides progress, add:

  • .env.example and startup configuration validation;
  • container image and health/readiness endpoints;
  • database migration review;
  • request id, logs, metrics, traces;
  • CI checks for typecheck, tests, lint, and dependency/security scanning;
  • deployment settings: resource limits, graceful shutdown, secret injection, and rollback plan.

10. Learn technologies as interchangeable tools

The course examples use one route, one validation library, one database client, and one queue so you can see the system. Learn the trade-off behind each, then compare alternatives:

ResponsibilityExampleOther common choices
React web appNext.jsRemix, Vite React, Astro
HTTP APIFastifyExpress, NestJS, Hono, Next.js route handlers
ValidationZodValibot, JSON Schema, Joi
SQL accesspgPrisma, Drizzle, Kysely
Cache/queueRedis/BullMQRabbitMQ, SQS, Kafka, Temporal
DatabasePostgreSQLMySQL, SQLite, MongoDB for specific models
DeploymentDockerserverless, Kubernetes, managed platforms

Do not try to memorize every library. Learn the boundary, failure mode, and operational trade-off that makes a tool appropriate.