Skip to main content
Back to Blog
TypeScriptNext.jsNode.jsPostgreSQLFull-StackWeb Development2026ReactPrismaREST API

Full-Stack TypeScript for 2026: Building a Job-Ready App with Next.js, Node, and Postgres

Learn how to build a production-ready, full-stack TypeScript application in 2026 using Next.js 15, Node.js, and PostgreSQL. From project setup to deployment, this guide covers modern best practices, real-world code patterns, and job-ready architecture that top companies expect.

April 19, 202613 min readNiraj Kumar

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.

LayerTechnologyWhy
FrontendNext.js 15 (App Router)SSR, RSC, file-based routing
BackendNode.js + ExpressFamiliar, mature, widely deployed
DatabasePostgreSQLRelational, ACID-compliant, production-proven
ORMPrismaType-safe DB access, great DX
AuthNextAuth.js v5First-class Next.js integration
ValidationZodRuntime + compile-time safety
DeploymentVercel + RailwayZero-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 tsx for Node.js in dev instead of ts-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 seed script. This makes onboarding new devs effortless.
  • Type your Express Request by extending it via declaration merging to include user, rather than casting req.user everywhere.
  • Use RETURNING in raw SQL when Prisma's updateMany doesn't return updated records — or switch to update with a where clause when operating on a single record.
  • Cache aggressively in Next.js 15 — understand the difference between no-store, force-cache, and revalidate and choose deliberately.
  • Run prisma migrate dev in 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. 🚢

All Articles
TypeScriptNext.jsNode.jsPostgreSQLFull-StackWeb Development2026ReactPrismaREST API

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.