Build a production-ready Express.js API in 5 steps—routes, validation, security, and deployment. For solo founders who need working code, not theory.
Express.js is still the quickest way to launch a production-ready API as a solo founder. Here's the thing: you don't need a framework tutorial that runs for pages. Instead, follow a clear path from npm init to deploying endpoints managing auth, validation, and errors efficiently, even under load.
Photo: Growtika on Unsplash
Who this is for
You're crafting a SaaS, mobile app backend, or internal tool by yourself. You know JavaScript but want to avoid a deep dive into 40-page framework docs. An API should be live today, not next sprint. This guide assumes some Node.js familiarity and a terminal at your fingertips.
Step 1: Initialize Project and Install Core Dependencies
Start by creating a new directory and initializing npm. It's worth noting: most deployment issues are due to missing or mismatched dependency versions.
mkdir my-api && cd my-api
npm init -y
npm install express dotenv cors helmet
npm install --save-dev nodemon
What each package does:
express: the web framework itselfdotenv: loads environment variables from.envfilescors: handles cross-origin requests without headacheshelmet: sets security headers by defaultnodemon: restarts server on file changes during development
Create a .env file in your project root:
PORT=3000
NODE_ENV=development
Update package.json scripts:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
Many indie hackers ignore helmet and ship APIs with default headers. However, the Express.js security best practices documentation highlights that missing security headers can lead to severe vulnerabilities in production APIs.
Step 2: Set Up Basic Server with Middleware
Photo: Daniil Komov on Unsplash
Create server.js in your root directory. This is where your API resides.
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Middleware order matters. Begin with security middleware like helmet(). Body parsers like express.json() follow next. Then, route handlers. Finally, error handlers go last.
The health check endpoint isn't optional. Deployment platforms expect it for monitoring, load balancer checks, and debugging production issues.
Run your server:
npm run dev
Test it:
curl http://localhost:3000/health
Expect {"status":"ok","timestamp":"2026-01-15T10:30:00.000Z"} or similar.
Step 3: Build CRUD Routes with Proper Structure
Many solo founders pile all routes into one file. This approach is fine until you hit route 15. Separate concerns early on.
Create a routes/ directory and add routes/users.js:
const express = require('express');
const router = express.Router();
// In-memory store (replace with database in production)
let users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
// GET all users
router.get('/', (req, res) => {
res.json(users);
});
// GET single user
router.get('/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// POST new user
router.post('/', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email required' });
}
const newUser = {
id: users.length + 1,
name,
email
};
users.push(newUser);
res.status(201).json(newUser);
});
// PUT update user
router.put('/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
const { name, email } = req.body;
if (name) user.name = name;
if (email) user.email = email;
res.json(user);
});
// DELETE user
router.delete('/:id', (req, res) => {
const index = users.findIndex(u => u.id === parseInt(req.params.id));
if (index === -1) return res.status(404).json({ error: 'User not found' });
users.splice(index, 1);
res.status(204).send();
});
module.exports = router;
Update server.js to mount the routes:
const userRoutes = require('./routes/users');
// Add after middleware, before error handlers
app.use('/api/users', userRoutes);
Test your CRUD operations:
# Get all users
curl http://localhost:3000/api/users
# Create user
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Charlie","email":"charlie@example.com"}'
# Get single user
curl http://localhost:3000/api/users/1
# Update user
curl -X PUT http://localhost:3000/api/users/1 \
-H "Content-Type: application/json" \
-d '{"name":"Alice Updated"}'
# Delete user
curl -X DELETE http://localhost:3000/api/users/1
This in-memory store is fine for prototyping. For production, swap it with PostgreSQL via node-postgres, MongoDB via Mongoose, or SQLite via better-sqlite3.
Step 4: Add Input Validation and Error Handling
Catching mistakes with raw req.body validation can be late. Install a validator:
npm install joi
Create middleware/validate.js:
const Joi = require('joi');
const schemas = {
user: Joi.object({
name: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required()
}),
userUpdate: Joi.object({
name: Joi.string().min(2).max(50),
email: Joi.string().email()
}).min(1) // At least one field required
};
const validate = (schema) => {
return (req, res, next) => {
const { error } = schemas[schema].validate(req.body);
if (error) {
return res.status(400).json({
error: 'Validation failed',
details: error.details.map(d => d.message)
});
}
next();
};
};
module.exports = validate;
Update routes/users.js to use validation:
const validate = require('../middleware/validate');
// Replace POST route
router.post('/', validate('user'), (req, res) => {
const { name, email } = req.body;
const newUser = {
id: users.length + 1,
name,
email
};
users.push(newUser);
res.status(201).json(newUser);
});
// Replace PUT route
router.put('/:id', validate('userUpdate'), (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
const { name, email } = req.body;
if (name) user.name = name;
if (email) user.email = email;
res.json(user);
});
Test invalid input:
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"A","email":"not-an-email"}'
You'll receive a clear validation error instead of a 500 crash.
Step 5: Deploy to Production with Environment Config
Most solo founders mistakenly deploy with NODE_ENV=development. Set up proper environment separation.
Update .env for local development. Create .env.production (do NOT commit this):
PORT=8080
NODE_ENV=production
DATABASE_URL=your_database_connection_string
API_KEY=your_production_api_key
Add to .gitignore:
node_modules/
.env
.env.production
.env.local
For deployment, consider Railway, Render, or Fly.io. All three support Express.js deployments without needing any Docker config.
Railway example:
- Install Railway CLI:
npm i -g @railway/cli - Login:
railway login - Initialize:
railway init - Set environment variables in Railway dashboard
- Deploy:
railway up
Render example:
- Connect GitHub repo in Render dashboard
- Select "Web Service"
- Build command:
npm install - Start command:
npm start - Add environment variables in dashboard
- Deploy
According to the Node.js production best practices guide, always run with NODE_ENV=production to enable template caching, reduce verbose logging, and significantly boost performance.
Set up a simple deployment check script in package.json:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"check": "node -e \"console.log('Node version:', process.version); console.log('Env:', process.env.NODE_ENV)\""
}
Run after deployment:
npm run check
What Nobody Tells You About Express APIs
Async error handling breaks silently. Wrap async route handlers or use express-async-errors:
npm install express-async-errors
Add to top of server.js:
require('express-async-errors');
Now async errors get caught by your error handler automatically.
Rate limiting isn't optional. Your free-tier API will get hammered. Install protection:
npm install express-rate-limit
Add to server.js:
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});
app.use('/api/', limiter);
CORS misconfiguration kills launches. If your frontend struggles to reach your API, check CORS first. For development with specific origins:
app.use(cors({
origin: process.env.NODE_ENV === 'production'
? 'https://yourdomain.com'
: 'http://localhost:5173'
}));
Logging matters. Console.log disappears in production. Use a simple logger:
npm install pino pino-pretty
Replace console.log with structured logging—but that's a separate article.
Common Mistakes Solo Founders Make
Skipping input validation until production. APIs can throw 500 errors when someone sends "age": "twenty" instead of "age": 20. Validate at the route level, always.
Not handling OPTIONS requests for CORS preflight. Express with CORS manages this, but custom middleware might break it. Test with a real frontend, not just curl.
Running synchronous operations in route handlers. Reading files with fs.readFileSync() or doing CPU-heavy work blocks the entire event loop. Use async alternatives or offload to a worker queue.
Deploying without health checks. Deployment platforms require /health to return 200. Add it first, not when issues arise.
Forgetting to set trust proxy behind a reverse proxy (e.g., Nginx, load balancers). Without this, req.ip returns the proxy IP, not the client's:
app.set('trust proxy', 1);
FAQ
Do I need a framework like NestJS or Fastify instead of Express?
No. Express handles 90% of solo founder use cases. NestJS adds TypeScript decorators and dependency injection—ideal for teams but overkill for solo projects. Fastify is faster at a large scale, but you won't see a difference until over 10,000 requests per second. Start with Express, migrate only if clear bottlenecks appear.
How do I add authentication to these routes?
Install jsonwebtoken and bcrypt. Create a /auth/login route that returns a JWT. Add middleware to verify the token on protected routes. The pattern: middleware checks Authorization: Bearer <token>, verifies with jwt.verify(), attaches the user to req.user, and calls next().
Should I use TypeScript with Express?
If TypeScript is part of the daily routine, yes. If not, JavaScript allows faster shipping for solo projects. TypeScript introduces extra build config and requires type definitions for every package, which can slow iteration. Most solo API bugs stem from missing validation, not type errors. Use Joi for runtime validation—it catches what TypeScript can't.
What's the fastest way to add a database?
For prototyping: SQLite with better-sqlite3. For production: PostgreSQL via Supabase or Railway's built-in Postgres. Skip ORMs initially—write raw SQL or use a query builder like knex. Sequelize and TypeORM add complexity unnecessary for 0-1000 users.
Bottom Line
You now have a production-ready Express.js API structure that efficiently handles routes, validation, errors, and security. Don't add more features—deploy this, connect your frontend, and introduce it to real users.
Next step: Push your code to GitHub, link it to Railway or Render, and deploy in the next 15 minutes. The true test of finding missing edge cases is to run your API under real traffic. For rapid prototyping, consider tools like Bubble vs. Webflow: Which Is Best for Rapid Prototyping? to streamline your development process.
Editorial note: This article was produced with AI assistance and reviewed by Javier Valencia. Verified facts are distinguished from editorial opinion throughout the text. External sources linked are independent of NewsTide.
Sources
More in Dev Stack
🇪🇸 Also available in Spanish: Leer en español