A Node backend is a 50-line app.js that listens on a port, plus everything that comes after: folder structure, environment variables, routes, middleware, error handling, a health check, a database connection, a build, and a deploy. Most tutorials give you the 50 lines and stop; the real work is the 500 lines that come after. The 50 lines are the easy part. The hard part is making the 50 lines survive a redeploy without forgetting the database password, exposing a stack trace in production, or shipping a service that returns 200 to every request including the one that crashed.
The “create a Node backend” question is the most-asked Node question in 2026, and the most-answered version of it is the “hello world” version. The hello world version is technically correct, technically complete, and technically useless for anyone trying to ship an actual application. The version that follows is the one that ships: the realistic path from “I just installed Node” to “the service is in production, the health check is green, and I can roll back the last deploy with one click.”
Table of contents
- The decision that decides everything else: framework or no framework
- Step 1: install Node, pin the version, and pick a package manager
- Step 2: the folder structure that survives six months of work
- Step 3: the
app.jsthat is not a tutorial hello world - Step 4: routes, controllers, and the one place to put business logic
- Step 5: environment variables, secrets, and the
.envfile that should not be in git - Step 6: middleware, in the order that matters
- Step 7: the health check, the error handler, and the request logger
- Step 8: connect a database (the part tutorials skip)
- Step 9: test it locally, then deploy it
- The 12 things that bite in production
- FAQ
The decision that decides everything else: framework or no framework
Node ships with http.createServer. That is enough to handle every request your application will ever see. The question is whether to use that directly or to use a framework on top of it. The two honest options for a new project in 2026 are:
Express 5 (or 4). The default. Most tutorials, most examples, most StackOverflow answers. The API has not changed meaningfully in a decade. The library does not enforce an architecture, which is the source of both its flexibility and the “your codebase is a mess after 6 months” complaint.
Fastify. The modern alternative. The performance is meaningfully better than Express for high-throughput APIs. The schema-first validation (via JSON Schema) is the real differentiator — a request is validated before the handler runs, and the validator is the same shape as the OpenAPI spec. The plugin system is the part that makes the framework feel “framework-y” instead of “library-y.”
The raw http module. Fine for a one-off script. Not fine for anything that has more than one route. The boilerplate to write a router, middleware, and error handling from scratch is more code than the framework would have been, and the result is harder to maintain.
The default is Express 5. Fastify is the right choice if you are starting fresh, you control the architecture, and you have a need for high throughput or schema-first validation. The framework decision is the one that ripples through the rest of the codebase, which is why it goes first.
Step 1: install Node, pin the version, and pick a package manager
Node 22 is the current LTS. Node 20 is the previous LTS and is still fully supported. Pin the version in the project’s .nvmrc:
22
A teammate running nvm use will get the same Node version. The build server running nvm install will get the same Node version. The deploy platform reading the .nvmrc will get the same Node version. The reason for pinning is not “Node 22 is the only one that works”; the reason is “every contributor and every build runs the same version, so a bug in Node 23.4 is not a bug in your code.”
For a RunxBuild deploy, the .nvmrc file is read by the build pipeline. The pipeline installs the version, runs the build, and the running service uses the same version. No drift, no surprises.
Pick a package manager. The default is npm. The modern alternative is pnpm (faster, more disk-efficient, stricter about phantom dependencies). The other modern alternative is bun (much faster, but the runtime is not Node and the edge cases are different). For a new project in 2026, pnpm is the right default. For a tutorial that needs to be reproducible everywhere, npm is the right default.
npm init -y
The -y flag accepts the defaults. The result is a package.json with the name, version, and a single test script. Edit the file to add "type": "module" if you want ESM imports (import instead of require). For a new project in 2026, ESM is the right default.
Step 2: the folder structure that survives six months of work
A folder structure is a contract. The contract says “code that does the same thing lives in the same place.” A contract that is too rigid becomes a fight. A contract that is too loose becomes a mess. The middle is the structure below.
my-node-backend/
├── src/
│ ├── app.js # Express app, middleware, error handler
│ ├── server.js # Imports app, starts listening on a port
│ ├── routes/
│ │ ├── index.js # Mounts all routes
│ │ ├── users.js # /users routes
│ │ └── health.js # /health route
│ ├── controllers/
│ │ └── users.js # Business logic for /users
│ ├── middleware/
│ │ ├── auth.js # JWT verification
│ │ └── errorHandler.js # Final error formatter
│ ├── lib/
│ │ ├── db.js # Database connection pool
│ │ └── logger.js # Pino or Winston setup
│ └── config/
│ └── index.js # Reads env vars, exports a typed object
├── test/
│ └── users.test.js
├── .env.example # Template for env vars, in git
├── .env # Actual env vars, NOT in git
├── .gitignore
├── .nvmrc
├── package.json
├── pnpm-lock.yaml
└── README.md
The split is intentional. app.js builds the Express app. server.js starts the server. The two files are separate so a test can import app.js without starting a listener, and so a serverless platform can import app.js and let the platform handle the listening.
The controllers/ directory is where business logic lives. The routes/ directory is where HTTP-to-controller mapping lives. The middleware/ directory is for cross-cutting concerns. The lib/ directory is for utilities that do not depend on Express. The config/ directory is for env var parsing.
For a small project (one or two routes, no database), the structure is overkill. The break-even is around 200 lines of code in a single file. Before that, a single app.js is fine. After that, the structure pays for itself.
Step 3: the app.js that is not a tutorial hello world
The minimal Express 5 app is:
import express from 'express';
const app = express();
app.get('/', (req, res) => {
res.json({ message: 'Hello, world!' });
});
export default app;
The 7-line app is the tutorial hello world. The 50-line app is the one that ships:
import express from 'express';
import pinoHttp from 'pino-http';
import { config } from './config/index.js';
import { errorHandler } from './middleware/errorHandler.js';
import { router as healthRouter } from './routes/health.js';
import { router as usersRouter } from './routes/users.js';
const app = express();
// Trust the first proxy in production (for correct req.ip behind a load balancer)
if (config.env === 'production') {
app.set('trust proxy', 1);
}
// Built-in middleware
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: false }));
// Request logger
app.use(pinoHttp({ logger: config.logger }));
// Routes
app.use('/health', healthRouter);
app.use('/users', usersRouter);
// 404 handler (must be after all routes)
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Final error handler (must be last, takes 4 args)
app.use(errorHandler);
export default app;
The 50-line version adds six things the tutorial skips: a trust proxy setting (for correct IP addresses behind a load balancer), a JSON body parser with a 1 MB limit (so a 10 MB request body does not OOM the process), a request logger (so you can see what is happening in production), explicit route mounting (so the route prefix is in app.js, not in each route file), a 404 handler (so unknown routes do not fall through to the error handler), and a final error handler (so unhandled exceptions do not leak stack traces to clients).
The config object is the only place that reads process.env. The rest of the codebase imports config and gets typed values. The pino logger is the production logger — it is 5x faster than console.log and it writes structured JSON that a log tool can parse.
Step 4: routes, controllers, and the one place to put business logic
A route is a function that says “this HTTP method on this path runs this controller.” A controller is a function that takes a request, does the work, and returns a response. The split is the part that makes the codebase maintainable.
// src/routes/users.js
import { Router } from 'express';
import { getUsers, createUser } from '../controllers/users.js';
import { auth } from '../middleware/auth.js';
export const router = Router();
router.get('/', auth, getUsers);
router.post('/', auth, createUser);
// src/controllers/users.js
import { db } from '../lib/db.js';
export async function getUsers(req, res) {
const users = await db.query('SELECT id, email FROM users WHERE org_id = $1', [req.user.orgId]);
res.json(users);
}
export async function createUser(req, res) {
const { email } = req.body;
if (!email) {
return res.status(400).json({ error: 'email is required' });
}
const user = await db.query(
'INSERT INTO users (email, org_id) VALUES ($1, $2) RETURNING *',
[email, req.user.orgId]
);
res.status(201).json(user);
}
The route file maps HTTP to controllers. The controller file contains the business logic. The split means a test can import a controller and test it without spinning up Express. A test can also import a route and test it with a mocked request/response. The split is the part that makes the codebase testable.
The auth middleware in the route definition is the second thing. The middleware runs before the controller, attaches req.user if the JWT is valid, and rejects the request if it is not. The middleware is mounted on each route that needs auth, not in app.js, so the /health route is public.
Step 5: environment variables, secrets, and the .env file that should not be in git
The .env file is the dev-time way to pass secrets to the application. The file is loaded by the application at startup, the values become process.env.VAR_NAME, and the file is in .gitignore so it never gets committed.
The .env.example file is the template. Every variable the application needs is in the file with a placeholder value. The file is in git. A new contributor copies the file to .env, fills in the values, and the application starts.
# .env.example
NODE_ENV=development
PORT=3000
LOG_LEVEL=info
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
JWT_SECRET=replace-with-32-byte-random-string
The config/index.js file reads the env vars, validates them, and exports a typed object. The validation is the part that turns a runtime error into a startup error:
// src/config/index.js
import { z } from 'zod';
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const config = {
env: parsed.data.NODE_ENV,
port: parsed.data.PORT,
logLevel: parsed.data.LOG_LEVEL,
databaseUrl: parsed.data.DATABASE_URL,
jwtSecret: parsed.data.JWT_SECRET,
};
The application exits with a non-zero code at startup if the env vars are missing. The application never starts with bad config. The z.coerce.number() is the part that turns the string "3000" from process.env into the number 3000. The z.string().min(32) is the part that rejects a 10-character JWT secret.
For production, the env vars come from the platform’s environment variable store, not from a .env file. The RunxBuild dashboard stores the env vars per service, the build pipeline injects them at runtime, and the .env file in the repo is irrelevant in production. The same config/index.js reads from process.env in both cases.
Step 6: middleware, in the order that matters
The middleware order is the order the request flows through. A request hits the first middleware, then the second, then the third, until a middleware sends a response. The order matters for two reasons: middleware that mutates the request must run before the route handler, and middleware that mutates the response must run after the route handler but before the response is sent.
The order for a production app:
app.set('trust proxy', ...)— runs before everything else soreq.ipis correct.express.json()— runs before the routes soreq.bodyis parsed.- Request logger — runs early so the request is logged even if a later middleware throws.
- CORS — runs before the routes so preflight requests are handled.
- Routes — the actual route handlers.
- 404 handler — runs after all routes, so any unmatched path gets a JSON 404.
- Final error handler — runs last, with 4 arguments, so Express knows it is the error handler.
A common mistake is to put the request logger after the routes, which means the request is not logged until after the route handler runs. For a successful request, that is fine. For a request that hangs in a middleware, the request is not logged at all, and the team is staring at a slow production with no log line to look at.
Another common mistake is to put the CORS middleware after the routes. CORS needs to handle the preflight OPTIONS request, which Express dispatches before the route handler. If CORS is after the routes, the preflight is not handled and the browser blocks the actual request with a CORS error.
Step 7: the health check, the error handler, and the request logger
The three pieces are the part that separates a “hello world” from a “production app.”
The health check.
// src/routes/health.js
import { Router } from 'express';
import { db } from '../lib/db.js';
export const router = Router();
router.get('/', async (req, res) => {
try {
await db.query('SELECT 1');
res.json({ status: 'ok', database: 'connected' });
} catch (err) {
res.status(503).json({ status: 'unhealthy', database: 'disconnected' });
}
});
The health check returns 200 if the application and the database are both reachable, 503 if either is down. The platform uses the 200 to decide whether to send traffic to the instance, and the 503 to decide whether to restart it. A health check that does not check the database is the most common cause of “the platform says the app is up but every request 500s.”
The error handler.
// src/middleware/errorHandler.js
import { logger } from '../lib/logger.js';
export function errorHandler(err, req, res, next) {
// Log the full error with the request context
req.log.error({ err }, 'Unhandled error');
// Send a safe response to the client
const status = err.status || 500;
res.status(status).json({
error: status === 500 ? 'Internal server error' : err.message,
});
}
The error handler logs the full error with the request context, so the log line includes the request ID, the user ID, and the stack trace. The response to the client is a safe version of the error message. The “Internal server error” string is the part that prevents stack traces from leaking to clients.
The request logger.
The pino-http middleware from Step 3 logs every request with the method, URL, status code, and response time. The log line is one JSON object per request, which a log tool can parse and aggregate. A team that has tried to debug a production issue with console.log knows that structured logging is the part that makes the issue debuggable.
Step 8: connect a database (the part tutorials skip)
A Node backend that does not talk to a database is a static page. The lib/db.js file is the part that connects the application to a Postgres database, manages the connection pool, and exposes a query function.
// src/lib/db.js
import pg from 'pg';
import { config } from '../config/index.js';
const { Pool } = pg;
export const db = new Pool({
connectionString: config.databaseUrl,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
db.on('error', (err) => {
logger.error({ err }, 'Database pool error');
});
The connection pool is the part that makes the application handle concurrent requests without opening a new connection per request. The max: 10 is the maximum number of connections in the pool, and the idleTimeoutMillis is the time a connection can be idle before it is closed. The two timeouts are the part that prevents connection leaks.
For a managed Postgres database on RunxBuild, the DATABASE_URL comes from the dashboard, the pool is the same shape, and the connection is encrypted in transit. The platform handles the backups, the replication, and the upgrade path. The application code does not change.
Step 9: test it locally, then deploy it
The local test is the part that catches 80% of bugs before they ship:
# Install
pnpm install
# Set up the env
cp .env.example .env
# Edit .env with real values
# Run the database (use docker for a local Postgres)
docker run --name pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16
# Run the migrations
pnpm migrate
# Start the app
pnpm dev
# Test
curl http://localhost:3000/health
# {"status":"ok","database":"connected"}
curl http://localhost:3000/users
# []
The deploy is the part that takes the local app and puts it on a public URL. The deploy is a single command on a managed platform like RunxBuild, and the platform handles the rest. The deploy command is one of:
# Render Blueprint
render deploy
# Fly.io
fly deploy
# RunxBuild
rbx deploy
The platform reads the .nvmrc, installs Node, runs pnpm install, runs the build, and starts the server. The environment variables are pulled from the platform’s secret store. The health check URL (/health) is configured in the platform’s settings. The platform restarts the service if the health check fails, and the team gets a Slack alert.
The 12 things that bite in production
A short, opinionated list. None of these are in the tutorial.
1. The build works locally but fails on the platform. The cause is almost always a missing environment variable or a different Node version. The fix is to make the platform’s env match the local env and to commit the .nvmrc.
2. The first request after a deploy takes 5 seconds. The platform is starting the application. The fix is to set the minimum number of instances to 1 (so the app is always warm) or to use a health check that gives the app time to start.
3. The app uses 100% CPU in production. The cause is usually an unhandled error loop, a synchronous CPU-bound operation in a route handler, or a memory leak. The fix is to use the platform’s CPU profiler to find the hot path.
4. The database connection pool is exhausted. The cause is usually a query that takes too long, or a connection that is not being released. The fix is to use the platform’s slow query log and to set a query timeout.
5. The JWT_SECRET rotated and every user is logged out. The cause is a redeploy. The fix is to support multiple valid secrets during a rotation (read the new secret first, fall back to the old one).
6. The CORS preflight fails in production. The cause is a missing Access-Control-Allow-Origin header on the response. The fix is to check the CORS middleware and confirm the origin is in the allow list.
7. The 404 page is HTML instead of JSON. The cause is Express’s default 404 handler, which sends HTML. The fix is to add a JSON 404 handler (shown in Step 3).
8. The error response leaks the stack trace. The cause is the default Express error handler, which sends the full error. The fix is the custom error handler (shown in Step 7).
9. The package-lock.json is out of sync. The cause is a teammate running npm install instead of pnpm install. The fix is to use the same package manager in CI and locally.
10. The .env file is in git. The cause is a missing .gitignore entry. The fix is to add .env to .gitignore, remove the file from the repo (git rm --cached .env), and rotate any secrets that were in the file.
11. The deployment succeeds but the service does not respond. The cause is usually the platform starting the app, the health check failing, and the platform restarting it in a loop. The fix is to look at the platform’s deploy logs to find the actual error.
12. The app restarts on every deploy and the user sees a 502. The cause is the platform’s health check not being fast enough. The fix is to add a readiness check (the app is up) separate from the health check (the app can serve traffic).
FAQ
Do I need a framework for a Node backend?
No. The built-in http module can serve any request. The reason to use a framework (Express, Fastify) is the routing, middleware, and error handling, which you would have to write from scratch without one. For a one-route script, the http module is enough. For anything else, a framework is the right default.
Express 4 or Express 5?
Express 5 is the current version as of 2026. Express 4 is still widely used and fully supported, and most tutorials you find online are still on Express 4. The two are mostly compatible. For a new project, Express 5 is the right default. For an existing project, the migration is a half-day of work.
Should I use TypeScript or plain JavaScript?
TypeScript for a new project in 2026, if you have a team that knows it. The type checking catches a class of bugs at compile time that would otherwise surface in production. The cost is the build step (TypeScript compiles to JavaScript) and the learning curve. For a solo project or a quick prototype, plain JavaScript with JSDoc comments is the right default.
How do I deploy a Node backend?
Three honest options. A managed application platform (RunxBuild, Render, Fly.io) takes the repo, builds it, deploys it, and gives you a URL. A Platform-as-a-Service (Heroku, App Engine) is similar. A container orchestrator (Kubernetes, ECS) is the right choice for a large team with platform engineering. For a solo project, the managed platform is the right default.
What is the best Node backend for a small project?
Express is the safe default. Fastify is faster and has better validation. Hono is the new entrant for edge runtimes. For a small project, the framework is not the bottleneck — the database, the deploy, and the env vars are the bottlenecks. Pick a framework you know, ship the project, and switch frameworks if the project grows into one that needs a different shape.
How do I handle environment variables in production?
The platform’s secret store, not a .env file. The .env file is the dev-time pattern. The production pattern is the platform’s environment variable configuration, which the platform injects into the application at startup. The same process.env reads both.
What is the difference between a health check and a readiness check?
A health check is “is the process alive.” A readiness check is “is the process ready to serve traffic.” A process can be alive (the process is running) but not ready (the database is unreachable, the migrations have not run). A platform that only checks health will send traffic to a not-ready process, which 500s every request. A platform that checks readiness will wait until the process is ready before sending traffic, which is what production wants.