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
Root package.json
{
"workspaces": ["packages/*"],
"scripts": {
"dev": "turbo run dev --parallel",
"build": "turbo run build",
"test": "turbo run test"
}
}
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}`);
});
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 };
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 });
});
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"]
docker-compose up -d and the environment is ready.
Testing Structure
tests/
├── unit/
│ └── services/
├── integration/
│ └── routes/
└── fixtures/
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
Why This Stack
- Monorepo: One repo, one CI/CD, type sharing, easier refactoring
-
Three packages:
webdeploys to Vercel,apideploys anywhere,sharedis 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.
Top comments (0)