If you've been watching the job boards lately, you already know: TypeScript is no longer optional. From early-stage startups to FAANG-adjacent companies, full-stack TypeScript has become the lingua franca of modern web development. And when paired with Next.js, Node.js, and PostgreSQL, you get a stack that is battle-tested, recruiter-approved, and genuinely enjoyable to build with.
In this guide, we're going to build a Task Management API + Web App ā a realistic, job-board-worthy project ā while covering the architecture decisions, patterns, and pitfalls that separate junior developers from strong mid-level engineers.
Whether you're a beginner stepping into TypeScript for the first time or an intermediate dev wanting to sharpen your full-stack game, this post is for you.
š§± The Stack We're Building With
Before we write a single line of code, let's understand why this stack.
| Layer | Technology | Why |
|---|---|---|
| Frontend | Next.js 15 (App Router) | SSR, RSC, file-based routing |
| Backend | Node.js + Express | Familiar, mature, widely deployed |
| Database | PostgreSQL | Relational, ACID-compliant, production-proven |
| ORM | Prisma | Type-safe DB access, great DX |
| Auth | NextAuth.js v5 | First-class Next.js integration |
| Validation | Zod | Runtime + compile-time safety |
| Deployment | Vercel + Railway | Zero-config production deploys |
š Project Structure
A clean architecture makes your app maintainable and understandable to teammates (and future interviewers reading your GitHub). Here's the structure we'll use:
my-app/
āāā apps/
ā āāā web/ # Next.js frontend
ā ā āāā app/
ā ā ā āāā (auth)/
ā ā ā ā āāā login/page.tsx
ā ā ā āāā dashboard/
ā ā ā ā āāā page.tsx
ā ā ā āāā api/
ā ā ā ā āāā auth/[...nextauth]/route.ts
ā ā ā āāā layout.tsx
ā ā āāā components/
ā ā āāā lib/
ā ā āāā next.config.ts
ā āāā api/ # Express API server
ā āāā src/
ā ā āāā routes/
ā ā āāā controllers/
ā ā āāā middleware/
ā ā āāā services/
ā ā āāā index.ts
ā āāā tsconfig.json
āāā packages/
ā āāā db/ # Prisma schema + client
ā ā āāā prisma/
ā ā ā āāā schema.prisma
ā ā āāā index.ts
ā āāā types/ # Shared TypeScript types
ā āāā index.ts
āāā package.json # Turborepo root
āāā turbo.json
This is a monorepo setup using Turborepo. Monorepos are the industry standard for teams and are increasingly expected in mid-level+ roles.
āļø Setting Up the Monorepo
1. Initialize with Turborepo
npx create-turbo@latest my-app
cd my-app
2. Configure the root package.json
{
"name": "my-app",
"private": true,
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
"turbo": "^2.3.0",
"typescript": "^5.7.0"
}
}
3. Set up the shared packages/types package
// packages/types/index.ts
export interface Task {
id: string;
title: string;
description: string | null;
status: "TODO" | "IN_PROGRESS" | "DONE";
priority: "LOW" | "MEDIUM" | "HIGH";
userId: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreateTaskInput {
title: string;
description?: string;
priority?: Task["priority"];
}
export interface UpdateTaskInput {
title?: string;
description?: string;
status?: Task["status"];
priority?: Task["priority"];
}
export interface ApiResponse<T> {
data: T | null;
error: string | null;
success: boolean;
}
Sharing types between your frontend and backend is one of the most powerful advantages of a TypeScript monorepo. One type definition, zero drift.
šļø Database Layer with Prisma + PostgreSQL
Defining the Schema
// packages/db/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
image String?
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Task {
id String @id @default(cuid())
title String
description String?
status TaskStatus @default(TODO)
priority Priority @default(MEDIUM)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
}
enum TaskStatus {
TODO
IN_PROGRESS
DONE
}
enum Priority {
LOW
MEDIUM
HIGH
}
Exporting the Prisma Client (Singleton Pattern)
// packages/db/index.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
export * from "@prisma/client";
The singleton pattern prevents creating multiple Prisma client instances during hot-reloads in development ā a very common source of "too many connections" errors.
š ļø Building the Node.js API
Express + TypeScript Setup
// apps/api/src/index.ts
import express from "express";
import cors from "cors";
import helmet from "helmet";
import { taskRouter } from "./routes/tasks";
import { errorHandler } from "./middleware/errorHandler";
import { authenticateRequest } from "./middleware/auth";
const app = express();
const PORT = process.env.PORT ?? 4000;
// Middleware
app.use(helmet());
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(express.json());
// Routes
app.use("/api/tasks", authenticateRequest, taskRouter);
// Error handler (must be last)
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`š API running on http://localhost:${PORT}`);
});
Input Validation with Zod
// apps/api/src/schemas/task.schema.ts
import { z } from "zod";
export const createTaskSchema = z.object({
title: z.string().min(1, "Title is required").max(255),
description: z.string().max(1000).optional(),
priority: z.enum(["LOW", "MEDIUM", "HIGH"]).default("MEDIUM"),
});
export const updateTaskSchema = z.object({
title: z.string().min(1).max(255).optional(),
description: z.string().max(1000).optional(),
status: z.enum(["TODO", "IN_PROGRESS", "DONE"]).optional(),
priority: z.enum(["LOW", "MEDIUM", "HIGH"]).optional(),
});
export type CreateTaskInput = z.infer<typeof createTaskSchema>;
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>;
Task Service Layer
// apps/api/src/services/task.service.ts
import { prisma } from "@my-app/db";
import type { CreateTaskInput, UpdateTaskInput } from "../schemas/task.schema";
export const taskService = {
async getAllByUser(userId: string) {
return prisma.task.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
});
},
async getById(id: string, userId: string) {
return prisma.task.findFirst({
where: { id, userId },
});
},
async create(userId: string, data: CreateTaskInput) {
return prisma.task.create({
data: { ...data, userId },
});
},
async update(id: string, userId: string, data: UpdateTaskInput) {
return prisma.task.updateMany({
where: { id, userId },
data,
});
},
async delete(id: string, userId: string) {
return prisma.task.deleteMany({
where: { id, userId },
});
},
};
Task Controller + Routes
// apps/api/src/controllers/task.controller.ts
import type { Request, Response, NextFunction } from "express";
import { taskService } from "../services/task.service";
import { createTaskSchema, updateTaskSchema } from "../schemas/task.schema";
export const getTasks = async (req: Request, res: Response, next: NextFunction) => {
try {
const tasks = await taskService.getAllByUser(req.user.id);
res.json({ data: tasks, error: null, success: true });
} catch (err) {
next(err);
}
};
export const createTask = async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = createTaskSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
data: null,
error: parsed.error.flatten(),
success: false,
});
}
const task = await taskService.create(req.user.id, parsed.data);
res.status(201).json({ data: task, error: null, success: true });
} catch (err) {
next(err);
}
};
export const updateTask = async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = updateTaskSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
data: null,
error: parsed.error.flatten(),
success: false,
});
}
await taskService.update(req.params.id, req.user.id, parsed.data);
res.json({ data: { id: req.params.id }, error: null, success: true });
} catch (err) {
next(err);
}
};
export const deleteTask = async (req: Request, res: Response, next: NextFunction) => {
try {
await taskService.delete(req.params.id, req.user.id);
res.json({ data: null, error: null, success: true });
} catch (err) {
next(err);
}
};
// apps/api/src/routes/tasks.ts
import { Router } from "express";
import { getTasks, createTask, updateTask, deleteTask } from "../controllers/task.controller";
export const taskRouter = Router();
taskRouter.get("/", getTasks);
taskRouter.post("/", createTask);
taskRouter.patch("/:id", updateTask);
taskRouter.delete("/:id", deleteTask);
ā” Next.js Frontend with App Router
Fetching Data with Server Components
// apps/web/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { TaskList } from "@/components/TaskList";
async function getTasks(userId: string) {
const res = await fetch(`${process.env.API_URL}/api/tasks`, {
headers: {
Authorization: `Bearer ${userId}`, // replace with real token
},
// Cache for 30s, revalidate in background (Next.js 15 default)
next: { revalidate: 30 },
});
if (!res.ok) throw new Error("Failed to fetch tasks");
return res.json();
}
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
const { data: tasks } = await getTasks(session.user.id);
return (
<main className="container mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">My Tasks</h1>
<TaskList initialTasks={tasks} />
</main>
);
}
Client Component with Optimistic Updates
// apps/web/components/TaskList.tsx
"use client";
import { useState, useOptimistic } from "react";
import type { Task } from "@my-app/types";
import { TaskCard } from "./TaskCard";
import { createTask } from "@/app/actions/tasks";
interface TaskListProps {
initialTasks: Task[];
}
export function TaskList({ initialTasks }: TaskListProps) {
const [tasks, setTasks] = useState(initialTasks);
const [optimisticTasks, addOptimisticTask] = useOptimistic(
tasks,
(state, newTask: Task) => [newTask, ...state]
);
async function handleCreate(formData: FormData) {
const title = formData.get("title") as string;
if (!title.trim()) return;
const tempTask: Task = {
id: crypto.randomUUID(),
title,
description: null,
status: "TODO",
priority: "MEDIUM",
userId: "",
createdAt: new Date(),
updatedAt: new Date(),
};
addOptimisticTask(tempTask);
const result = await createTask({ title });
if (result.success && result.data) {
setTasks((prev) => [result.data!, ...prev]);
}
}
return (
<div>
<form action={handleCreate} className="mb-6 flex gap-2">
<input
name="title"
placeholder="New task..."
className="flex-1 border rounded-lg px-4 py-2"
required
/>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
>
Add Task
</button>
</form>
<ul className="space-y-3">
{optimisticTasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</ul>
</div>
);
}
Server Actions
// apps/web/app/actions/tasks.ts
"use server";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import type { ApiResponse, Task, CreateTaskInput } from "@my-app/types";
export async function createTask(input: CreateTaskInput): Promise<ApiResponse<Task>> {
const session = await auth();
if (!session?.user) {
return { data: null, error: "Unauthorized", success: false };
}
try {
const res = await fetch(`${process.env.API_URL}/api/tasks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.user.id}`,
},
body: JSON.stringify(input),
});
const result = await res.json();
if (result.success) {
revalidatePath("/dashboard");
}
return result;
} catch {
return { data: null, error: "Something went wrong", success: false };
}
}
š Authentication with NextAuth.js v5
// apps/web/lib/auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@my-app/db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
callbacks: {
session({ session, user }) {
session.user.id = user.id;
return session;
},
},
});
ā Best Practices for 2026
1. Always Use Strict TypeScript
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
2. Separate Concerns with a Service Layer
Never put business logic in your route handlers or React components. A dedicated service layer keeps things testable and maintainable. Controllers handle HTTP, services handle logic, repositories handle data.
3. Use Environment Variable Validation
// lib/env.ts
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
GITHUB_CLIENT_ID: z.string(),
GITHUB_CLIENT_SECRET: z.string(),
API_URL: z.string().url(),
});
export const env = envSchema.parse(process.env);
Fail fast at startup ā not at runtime in production.
4. Keep Server and Client Boundaries Clear
In Next.js App Router, never import server-only code into client components. Use the server-only package to enforce this:
// lib/db-helpers.ts
import "server-only";
// This file can never be accidentally imported in a Client Component
5. Use Database Indexes Strategically
Always index foreign keys and frequently queried columns. In Prisma:
@@index([userId])
@@index([status])
@@index([userId, status]) // composite for filtered queries
ā Common Mistakes to Avoid
1. any is the Enemy
// ā Bad
const data: any = await fetchUser();
// ā
Good
const data: User = await fetchUser();
2. Not Handling Loading and Error States
Every async operation in your UI needs three states: loading, success, and error. Use React's useTransition or Suspense boundaries appropriately.
3. Leaking Server Secrets to the Client
In Next.js, environment variables without the NEXT_PUBLIC_ prefix are server-only. Never expose secrets like DATABASE_URL or API keys to client components.
4. Ignoring Prisma's select and include
Fetching entire records when you only need two fields wastes bandwidth and memory:
// ā Fetches everything
const users = await prisma.user.findMany();
// ā
Fetch only what you need
const users = await prisma.user.findMany({
select: { id: true, name: true, email: true },
});
5. Skipping Input Validation on the Server
Never trust client-sent data. Even if your frontend validates, always re-validate on the backend with Zod or a similar library.
6. Forgetting to Scope Queries by userId
Every data query should include the authenticated user's ID. Omitting this is a serious security vulnerability ā one user could read or modify another user's data.
// ā Dangerous
const task = await prisma.task.findUnique({ where: { id } });
// ā
Safe ā scoped to the authenticated user
const task = await prisma.task.findFirst({ where: { id, userId } });
š Pro Tips
- Use
tsxfor Node.js in dev instead ofts-node. It's significantly faster and supports modern TypeScript without extra config. - Leverage Turbopack in Next.js 15 dev mode ā it's now stable and dramatically speeds up hot reload, especially in large codebases.
- Seed your database for development using Prisma's
seedscript. This makes onboarding new devs effortless. - Type your Express
Requestby extending it via declaration merging to includeuser, rather than castingreq.usereverywhere. - Use
RETURNINGin raw SQL when Prisma'supdateManydoesn't return updated records ā or switch toupdatewith a where clause when operating on a single record. - Cache aggressively in Next.js 15 ā understand the difference between
no-store,force-cache, andrevalidateand choose deliberately. - Run
prisma migrate devin CI before tests so your test DB schema always matches the schema file. - Put your shared Zod schemas in
packages/typesā you can reuse them for frontend form validation and backend API validation with zero duplication.
š Key Takeaways
- TypeScript monorepos are now the industry standard for full-stack apps. Turborepo makes them approachable without complex build tooling.
- Prisma + PostgreSQL is the go-to ORM choice in 2026 for type-safe, reliable database access. Understand your schema and migrations deeply.
- Next.js App Router blurs the line between frontend and backend ā Server Components, Server Actions, and Route Handlers give you a powerful unified model, but require discipline around server/client boundaries.
- Zod should be your single source of truth for validation. Use it on both the client (form validation) and the server (API validation) to prevent type drift.
- Security is structural, not optional. Always scope database queries to the authenticated user, validate all inputs server-side, and keep secrets out of client bundles.
- Separate concerns properly. Routes ā Controllers ā Services ā Repositories is a pattern that scales from side projects to production teams.
- Optimistic UI with
useOptimistic(React 19+) dramatically improves perceived performance and is straightforward to implement with Server Actions.
š Conclusion
Full-stack TypeScript in 2026 is not just a trend ā it's the baseline expectation for modern web engineering roles. The stack we've covered ā Next.js 15, Node.js + Express, PostgreSQL, Prisma, and Zod ā represents the practical intersection of what companies are hiring for and what genuinely works at scale.
The patterns in this post (monorepo architecture, service layers, type-safe validation, scoped queries, server/client boundary discipline) are the same ones you'll encounter in production codebases at real companies. Learning them now doesn't just make you a better developer ā it makes you a more confident one.
Your next step? Take this architecture, build something real with it, and put it on GitHub. A well-structured, TypeScript-first full-stack project is one of the strongest signals you can send to a technical hiring team in 2026.
Now go ship something. š¢