Full-stack development requires mastery of both frontend and backend technologies, but building truly great applications goes beyond just knowing how to write code. As Martin Fowler, author of Patterns of Enterprise Application Architecture and one of the original signatories of the Agile Manifesto, has argued, good architecture is about making future changes easy, not just getting the current version to work. It requires understanding architecture patterns, making informed technology choices, implementing security best practices, and writing maintainable code that scales.
This guide covers essential best practices for modern full-stack development, from project structure and API design to authentication, database strategies, testing, and deployment. Whether you're building your first full-stack application or refining your approach, these principles will help you create professional-grade software. They're also the same standards I apply as a full-stack developer working with startups and established businesses.
Project Architecture and Organization
Monorepo vs Polyrepo
The first architectural decision is how to organize your codebase. Should frontend and backend live in the same repository or separately?
Monorepo Approach:
project/
├── apps/
│ ├── web/ # Next.js frontend
│ └── api/ # Node.js backend
├── packages/
│ ├── ui/ # Shared UI components
│ ├── types/ # Shared TypeScript types
│ └── utils/ # Shared utilities
├── package.json
└── turbo.json # Turborepo config- ✓Easy code sharing between frontend and backend
- ✓Atomic commits across entire stack
- ✓Simplified dependency management
- ✗Requires tools like Turborepo or Nx
Polyrepo Approach:
# Separate repositories:
frontend/ # React/Next.js app
backend/ # Express/Fastify API
mobile/ # React Native app (optional)- ✓Clear separation of concerns
- ✓Independent deployment pipelines
- ✓Easier to scale teams
- ✗Harder to share code and types
Recommendation: Use a monorepo for small-to-medium projects and startups where rapid iteration matters. Use polyrepo when you have multiple independent services or large teams working on different parts of the stack.
Folder Structure Best Practices
src/
├── app/ # Next.js App Router
│ ├── (auth)/ # Route groups
│ ├── api/ # API routes
│ └── [locale]/ # Internationalization
├── components/
│ ├── ui/ # Reusable UI components
│ ├── features/ # Feature-specific components
│ └── layouts/ # Layout components
├── lib/
│ ├── api/ # API client functions
│ ├── auth/ # Authentication logic
│ ├── db/ # Database utilities
│ └── utils/ # Helper functions
├── types/ # TypeScript type definitions
├── constants/ # Application constants
└── hooks/ # Custom React hooksRESTful API Design Principles (Roy Fielding)
Resource-Based URLs
REST (Representational State Transfer), first defined by Roy Fielding in his 2000 doctoral dissertation, is built around resources. Use nouns to represent resources, not verbs. HTTP methods define the action.
// ✓ GOOD: RESTful design
GET /api/users # Get all users
GET /api/users/:id # Get specific user
POST /api/users # Create user
PUT /api/users/:id # Update user
DELETE /api/users/:id # Delete user
// Nested resources:
GET /api/users/:id/posts # Get user's posts
POST /api/users/:id/posts # Create post for user
// ✗ BAD: Non-RESTful design
GET /api/getUsers
POST /api/createUser
POST /api/updateUser
POST /api/deleteUserConsistent Response Format
Standardize your API responses for easier client-side handling.
// Success response:
{
"success": true,
"data": {
"id": 1,
"name": "John Doe",
"email": "john@example.com"
},
"message": "User retrieved successfully"
}
// Error response:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid email format",
"details": {
"field": "email",
"value": "invalid-email"
}
}
}
// Paginated response:
{
"success": true,
"data": [...],
"pagination": {
"page": 1,
"limit": 20,
"total": 150,
"pages": 8
}
}HTTP Status Codes
- •200 OK: Successful GET, PUT, PATCH, DELETE
- •201 Created: Successful POST that creates a resource
- •204 No Content: Successful DELETE with no response body
- •400 Bad Request: Client error (validation, malformed request)
- •401 Unauthorized: Authentication required or failed
- •403 Forbidden: Authenticated but not authorized
- •404 Not Found: Resource doesn't exist
- •500 Internal Server Error: Server-side error
API Versioning
// URL versioning (recommended):
/api/v1/users
/api/v2/users
// Header versioning:
GET /api/users
Headers: Accept-Version: v1
// Query parameter versioning:
/api/users?version=1Authentication and Authorization
JWT-Based Authentication
JSON Web Tokens are the most common authentication method for modern web applications.
// Login endpoint:
POST /api/auth/login
{
"email": "user@example.com",
"password": "securePassword123"
}
// Response with tokens:
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 3600,
"user": {
"id": 1,
"email": "user@example.com",
"role": "user"
}
}
}
// Protected route middleware:
async function authenticateToken(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({
success: false,
error: { message: 'Authentication required' }
});
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({
success: false,
error: { message: 'Invalid or expired token' }
});
}
}Password Security
import bcrypt from 'bcrypt';
// Hash password before storing:
async function createUser(email, password) {
const saltRounds = 12; // Higher is more secure but slower
const hashedPassword = await bcrypt.hash(password, saltRounds);
return db.users.create({
email,
password: hashedPassword
});
}
// Verify password on login:
async function verifyPassword(plainPassword, hashedPassword) {
return await bcrypt.compare(plainPassword, hashedPassword);
}
// NEVER store plain-text passwords!
// NEVER log passwords, even hashed ones!
// NEVER send passwords in API responses!Role-Based Access Control (RBAC)
// Authorization middleware:
function requireRole(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
error: { message: 'Authentication required' }
});
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
success: false,
error: { message: 'Insufficient permissions' }
});
}
next();
};
}
// Usage:
app.delete('/api/users/:id',
authenticateToken,
requireRole('admin'),
deleteUser
);
// Role hierarchy:
const ROLES = {
USER: 'user',
MODERATOR: 'moderator',
ADMIN: 'admin',
SUPER_ADMIN: 'super_admin'
} as const;Database Best Practices
SQL vs NoSQL: Choosing the Right Database
Use SQL (PostgreSQL, MySQL) When:
- •You need complex queries and joins
- •Data has clear relationships (users, posts, comments)
- •ACID compliance is required (financial transactions)
- •Schema is well-defined and stable
Use NoSQL (MongoDB, Firestore) When:
- •Schema is flexible or frequently changing
- •Horizontal scaling is a priority
- •You're storing hierarchical or document-based data
- •Rapid prototyping with changing requirements
Connection Pooling
// PostgreSQL with connection pooling:
import { Pool } from 'pg';
const pool = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // Maximum pool size
idleTimeoutMillis: 30000, // Close idle clients after 30s
connectionTimeoutMillis: 2000,
});
// Use pool for queries:
export async function getUser(id: number) {
const result = await pool.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
return result.rows[0];
}Query Optimization
- 1.Add indexes on frequently queried columns: Dramatically speeds up SELECT queries, especially with WHERE clauses.
- 2.Use LIMIT for pagination: Never fetch all records at once.
- 3.SELECT only needed columns: SELECT id, name instead of SELECT *
- 4.Avoid N+1 queries: Use JOIN or batch queries instead of querying in loops.
- 5.Use prepared statements: Prevents SQL injection and improves performance.
Database Migrations
// Using Prisma for schema management:
// schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
createdAt DateTime @default(now())
}
// Generate migration:
// npx prisma migrate dev --name add-posts-table
// Apply migrations in production:
// npx prisma migrate deployError Handling and Logging
Centralized Error Handling
// Custom error classes:
class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public code?: string
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message: string, public field?: string) {
super(400, message, 'VALIDATION_ERROR');
}
}
class NotFoundError extends AppError {
constructor(resource: string) {
super(404, `${resource} not found`, 'NOT_FOUND');
}
}
// Global error handler middleware:
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
success: false,
error: {
code: err.code,
message: err.message,
},
});
}
// Log unexpected errors:
console.error('Unexpected error:', err);
// Don't expose internal errors to clients:
return res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
});
}Structured Logging
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
},
},
});
// Usage:
logger.info({ userId: 123 }, 'User logged in');
logger.error({ err, userId: 123 }, 'Failed to process payment');
// Request logging middleware:
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
logger.info({
method: req.method,
url: req.url,
status: res.statusCode,
duration: Date.now() - start,
}, 'Request completed');
});
next();
});Testing Strategies
Test Pyramid
- •70% Unit Tests: Test individual functions and components in isolation
- •20% Integration Tests: Test how modules work together
- •10% E2E Tests: Test complete user workflows
API Testing with Vitest
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
describe('User API', () => {
let authToken: string;
beforeAll(async () => {
// Setup: Login to get auth token
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'test@example.com',
password: 'password123'
});
authToken = response.body.data.accessToken;
});
describe('GET /api/users', () => {
it('should return list of users', async () => {
const response = await request(app)
.get('/api/users')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(Array.isArray(response.body.data)).toBe(true);
});
it('should require authentication', async () => {
const response = await request(app)
.get('/api/users');
expect(response.status).toBe(401);
});
});
});Deployment and DevOps
Environment Variables
// .env.example (commit this to git)
DATABASE_URL=
JWT_SECRET=
API_URL=
NODE_ENV=
// .env.local (never commit this)
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=your-secret-key-here
API_URL=http://localhost:3000
NODE_ENV=development
// Validate environment variables at startup:
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'production', 'test']),
});
export const env = envSchema.parse(process.env);CI/CD Pipeline
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
- run: npm ci
- run: npm run test
- run: npm run build
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: vercel/actions@v2
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.ORG_ID }}
vercel-project-id: ${{ secrets.PROJECT_ID }}Monitoring and Observability
- •Error Tracking: Sentry for frontend and backend errors
- •Performance Monitoring: New Relic or Datadog
- •Uptime Monitoring: UptimeRobot or Pingdom
- •Log Aggregation: Logtail or Papertrail
- •Analytics: Plausible or Vercel Analytics (privacy-friendly)
Performance Optimization
Caching Strategies
// Redis caching:
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function getUserWithCache(userId: number) {
const cacheKey = `user:${userId}`;
// Try cache first:
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Cache miss - fetch from database:
const user = await db.users.findUnique({ where: { id: userId } });
// Store in cache (expire after 1 hour):
await redis.setex(cacheKey, 3600, JSON.stringify(user));
return user;
}
// Invalidate cache on updates:
async function updateUser(userId: number, data: any) {
const user = await db.users.update({
where: { id: userId },
data,
});
// Clear cache:
await redis.del(`user:${userId}`);
return user;
}Database Query Optimization
// ✗ BAD: N+1 query problem
const users = await db.users.findMany();
for (const user of users) {
user.posts = await db.posts.findMany({
where: { authorId: user.id }
});
}
// ✓ GOOD: Use include to JOIN:
const users = await db.users.findMany({
include: {
posts: true,
},
});
// ✓ GOOD: Pagination with cursor:
const posts = await db.posts.findMany({
take: 20,
skip: 1,
cursor: {
id: lastPostId,
},
orderBy: {
createdAt: 'desc',
},
});Security Best Practices
- 1.Input Validation: Never trust user input. Validate and sanitize all data.
- 2.SQL Injection Prevention: Always use parameterized queries.
- 3.XSS Prevention: Sanitize HTML content, use Content Security Policy.
- 4.CSRF Protection: Use CSRF tokens for state-changing operations.
- 5.Rate Limiting: Prevent abuse with request rate limits.
- 6.Secrets Management: Never commit secrets to git. Use environment variables.
- 7.HTTPS Only: Enforce HTTPS in production. Use HSTS headers.
- 8.Dependency Updates: Regularly update packages to patch security vulnerabilities.
Building for the Long Term
Great full-stack development is about more than writing code that works today. It's about building systems that are maintainable, scalable, secure, and performant for years to come.
Focus on writing clean, well-tested code. Implement proper error handling and logging. Design APIs that are intuitive and versioned. Choose the right database for your use case. Automate testing and deployment. Monitor your application in production.
These best practices will serve you well across any technology stack or framework. The specific tools may change, but the fundamental principles remain constant.
If you're looking for someone to put these principles into practice on your project, I work as a freelance web developer specialising in Next.js, React, and Node.js. I also offer SEO-focused web development to make sure your application ranks as well as it performs.
Related Resources
React vs Next.js
When to use React alone vs a full-stack framework like Next.js for modern web architecture.
TypeScript for Enterprise Applications
How TypeScript enables type-safe, maintainable codebases at scale.
Web Performance Optimization
Core Web Vitals, caching strategies, and performance patterns for production applications.
SEO-Friendly Web Applications
Building web applications that rank — server-side rendering, metadata, and technical SEO for developers.
Development Services
Full-Stack Developer
End-to-end web application development with clean architecture, robust APIs, and scalable infrastructure.
Web Developer
Professional web development services — from marketing sites to complex web applications.
SEO Web Development
Web development built around search performance — technical SEO, Core Web Vitals, and content strategy baked in from day one.
Freelance Web Developer
Flexible, senior-level web development without the overhead of an agency or full-time hire.