DEV Community

Muhammad Zulqarnain
Muhammad Zulqarnain

Posted on

How I Structure Every Full Stack Project in 2025

I build a lot of full-stack projects. The first 50 lines of code set the pattern for the next 50,000.

Here's the structure I use for every project. Opinionated. Works.

The Monorepo Structure

project/
├── packages/
│   ├── web/                    # Next.js frontend
│   │   └── src/
│   │       ├── app/
│   │       ├── components/
│   │       ├── hooks/
│   │       └── lib/
│   ├── api/                    # Node.js backend
│   │   └── src/
│   │       ├── routes/
│   │       ├── controllers/
│   │       ├── services/
│   │       ├── middleware/
│   │       └── models/
│   └── shared/                 # Shared TypeScript types
│       └── src/
│           ├── types.ts
│           ├── constants.ts
│           └── validators.ts
├── docker-compose.yml
├── turbo.json
└── package.json
Enter fullscreen mode Exit fullscreen mode

Root package.json

{
  "workspaces": ["packages/*"],
  "scripts": {
    "dev": "turbo run dev --parallel",
    "build": "turbo run build",
    "test": "turbo run test"
  }
}
Enter fullscreen mode Exit fullscreen mode

Environment Management

// packages/shared/src/env.ts
const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET', 'REDIS_URL'];

export const env = {
  databaseUrl: process.env.DATABASE_URL!,
  jwtSecret: process.env.JWT_SECRET!,
  redisUrl: process.env.REDIS_URL!,
};

requiredEnvVars.forEach((key) => {
  if (!process.env[key]) throw new Error(`Missing required env var: ${key}`);
});
Enter fullscreen mode Exit fullscreen mode

Type Sharing

// packages/shared/src/types.ts
export type User = { id: string; email: string; name: string; createdAt: Date };
export type ApiResponse<T> = { success: boolean; data?: T; error?: string };
Enter fullscreen mode Exit fullscreen mode

Backend:

import { User, ApiResponse } from '@shared/types';
router.post('/users', async (req, res) => {
  const user: User = await db.users.create(req.body);
  res.json<ApiResponse<User>>({ success: true, data: user });
});
Enter fullscreen mode Exit fullscreen mode

Frontend uses same types — no mismatches, no runtime surprises.

Docker for Local Dev

version: '3'
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: mydb
    ports: ["5432:5432"]
  redis:
    image: redis:7
    ports: ["6379:6379"]
Enter fullscreen mode Exit fullscreen mode

docker-compose up -d and the environment is ready.

Testing Structure

tests/
├── unit/
│   └── services/
├── integration/
│   └── routes/
└── fixtures/
Enter fullscreen mode Exit fullscreen mode

CI/CD (GitHub Actions)

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm run lint
      - run: npm run test
      - run: npm run build
Enter fullscreen mode Exit fullscreen mode

Why This Stack

  • Monorepo: One repo, one CI/CD, type sharing, easier refactoring
  • Three packages: web deploys to Vercel, api deploys anywhere, shared is the contract between them
  • TypeScript everywhere: Catches errors at build time, not runtime
  • Turbo: Parallel builds, smart caching
  • Docker locally: Dev matches prod

Day 1 setup: monorepo + docker + shared types + CI/CD + 5 integration tests. Costs one week. Saves months.

zunain.com

Top comments (0)