The Skeleton of APIs & API-Endpoints in JavaScript
Building a universal API skeleton requires a structure that is modular, scalable, and predictable. Using Node.js with Express is the industry standard for this because it handles everything from simple microservices to massive enterprise systems.
Here is a professional-grade skeleton using the Controller-Service-Route pattern.
A "one-size-fits-all" API should be organized so you don't trip over your own code as it grows:
Routes: Define the URLs and HTTP methods.
Controllers: Handle the request/response logic (parsing params, sending status codes).
Services: The "brain"—where the actual database calls or business logic live.
Middleware: For auth, logging, and error handling.
app.js)This is your entry point. It sets up the server, security headers, and the global error handler.
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const app = express();
// --- Middleware Stack ---
app.use(helmet()); // Security headers
app.use(cors()); // Enable Cross-Origin requests
app.use(express.json()); // Parse JSON bodies
// --- Route Mounting ---
// Import your modular routes here
const resourceRoutes = require('./routes/resourceRoutes');
app.use('/api/v1/resources', resourceRoutes);
// --- 404 Handler ---
app.use((req, res) => {
res.status(404).json({ success: false, message: "Route not found" });
});
// --- Global Error Handler ---
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
res.status(status).json({
success: false,
error: err.message || "Internal Server Error",
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
To keep things clean, we split the endpoint into three files. This prevents your code from becoming a "spaghetti" mess.
routes/resourceRoutes.js):This defines what the URL looks like.
const express = require('express');
const router = express.Router();
const controller = require('../controllers/resourceController');
// Standard CRUD Pattern
router.get('/', controller.getAll);
router.get('/:id', controller.getOne);
router.post('/', controller.create);
router.put('/:id', controller.update);
router.delete('/:id', controller.remove);
module.exports = router;
controllers/resourceController.js):This manages the HTTP interface. It doesn't care how data is saved, only what was sent and what to send back.
const Service = require('../services/resourceService');
exports.getAll = async (req, res, next) => {
try {
const data = await Service.findAll();
res.status(200).json({ success: true, data });
} catch (err) { next(err); }
};
exports.create = async (req, res, next) => {
try {
const newItem = await Service.create(req.body);
res.status(201).json({ success: true, data: newItem });
} catch (err) { next(err); }
};
services/resourceService.js):This is where you talk to your Database (MongoDB, PostgreSQL, etc.).
// Example: Mocking a DB call
exports.findAll = async () => {
// return await Database.find({});
return [{ id: 1, name: "Universal Template" }];
};
exports.create = async (payload) => {
// return await Database.save(payload);
return { id: Date.now(), ...payload };
};
RESTful: It follows the standard GET/POST/PUT/DELETE pattern.
Scalable: Need a "Users" API? Just copy the folder structure and swap "Resource" for "User."
Safe: The global error handler ensures your server never crashes on a bad request; it just sends a polite 500 error instead.
Adding JWT (JSON Web Token) authentication is the best way to turn a basic skeleton into a production-ready API. It allows your server to verify who is making a request without having to store session data on the server.
middleware/auth.js)This function sits between the Route and the Controller. It checks the Authorization header for a valid token.
const jwt = require('jsonwebtoken');
const protect = (req, res, next) => {
let token;
// Check headers for "Bearer <token>"
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
token = req.headers.authorization.split(' ')[1];
}
if (!token) {
return res.status(401).json({ success: false, message: "Not authorized to access this route" });
}
try {
// Verify token against your secret key
const decoded = jwt.verify(token, 'YOUR_SECRET_KEY');
req.user = decoded; // Add user info to the request object
next();
} catch (err) {
return res.status(401).json({ success: false, message: "Token is invalid or expired" });
}
};
module.exports = protect;
Now you can choose which routes are public and which are private. Open your routes/resourceRoutes.js and apply the middleware:
const express = require('express');
const router = express.Router();
const controller = require('../controllers/resourceController');
const protect = require('../middleware/auth'); // Import the guard
// Public routes
router.get('/', controller.getAll);
// Protected routes (User must be logged in)
router.post('/', protect, controller.create);
router.delete('/:id', protect, controller.remove);
module.exports = router;
services/authService.js)When a user logs in successfully, you send them a signed token.
const jwt = require('jsonwebtoken');
exports.generateToken = (userId) => {
return jwt.sign(
{ id: userId },
'YOUR_SECRET_KEY',
{ expiresIn: '24h' } // Token expires in 1 day
);
};
Login: User sends credentials to /api/v1/auth/login.
Issue: Server validates credentials and returns a JWT.
Request: User sends a request to a protected route (e.g., POST /resources) with the token in the header:
Authorization: Bearer <your_token_here>
Validate: The protect middleware verifies the token and allows the request to reach the Controller.
Environment Variables: Never hardcode your YOUR_SECRET_KEY. Use a .env file and call it via process.env.JWT_SECRET.
HTTPS: Always use SSL in production; otherwise, tokens can be intercepted in transit.
Expiration: Keep token lifetimes short (e.g., 1 hour) and use "Refresh Tokens" for a better user experience.
To test your new API, you need a way to send requests and manage your JWT tokens automatically. Instead of a raw file, I’ll provide the structure for a Postman Collection (JSON) that you can import directly.
This template includes a Pre-request Script that automatically attaches your token to "Protected" requests once you log in.
Copy the code below, save it as collection.json, and import it into Postman.
JSON
{
"info": {
"name": "Universal API Skeleton",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Auth",
"item": [
{
"name": "Login User",
"event": [
{
"listen": "test",
"script": {
"exec": [
"const response = pm.response.json();",
"if (response.token) {",
" pm.environment.set('authToken', response.token);",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"test@example.com\",\n \"password\": \"password123\"\n}",
"options": { "json": { "language": "json" } }
},
"url": { "host": ["{{baseUrl}}"], "path": ["api", "v1", "auth", "login"] }
}
}
]
},
{
"name": "Resources",
"item": [
{
"name": "Get All Resources",
"request": {
"method": "GET",
"url": { "host": ["{{baseUrl}}"], "path": ["api", "v1", "resources"] }
}
},
{
"name": "Create Resource (Protected)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{authToken}}", "type": "text" }
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"New Project\",\n \"description\": \"Created via Postman\"\n}",
"options": { "json": { "language": "json" } }
},
"url": { "host": ["{{baseUrl}}"], "path": ["api", "v1", "resources"] }
}
}
]
}
]
}
For this collection to work, you need to create a Postman Environment with these variables:
| Variable | Initial Value | Description |
baseUrl |
http://localhost:3000 |
Your local server address |
authToken |
(Leave Blank) | This will be filled automatically after login |
In the Login User request under the Tests tab, I added a script. This is the "wit" of the setup:
How it works: When you click "Send" on the Login request, Postman grabs the token from the response body and saves it as
{{authToken}}. Every subsequent request with theAuthorization: Bearer {{authToken}}header will now work without you having to copy-paste the token.
Start your Node.js server.
Run the Login request: Ensure it returns a JSON object containing a field named token.
Run the Create Resource request: Check if it returns 201 Created. If you get a 401 Unauthorized, it means the token isn't being passed correctly.
Containerizing your API with Docker is the final step to ensuring it works perfectly on your machine, your teammate's machine, and the production server. No more "it worked on my localhost" excuses!
DockerfileCreate a file named Dockerfile (no extension) in your project root. This tells Docker how to build the "image" of your API.
Dockerfile
# Step 1: Use a slim version of Node.js as the base
FROM node:20-alpine
# Step 2: Create a directory for the app code
WORKDIR /usr/src/app
# Step 3: Copy package files and install dependencies
# We do this before copying the code to take advantage of Docker's cache
COPY package*.json ./
RUN npm install --production
# Step 4: Copy the rest of your application code
COPY . .
# Step 5: Expose the port your app runs on
EXPOSE 3000
# Step 6: Define the command to run your app
CMD ["node", "app.js"]
.dockerignoreYou don't want to copy your massive node_modules folder or your .env secrets into the image. Create a .dockerignore file:
Plaintext
node_modules
npm-debug.log
.env
.git
docker-compose.yml (The "Easy Button")If your API eventually needs a database (like MongoDB or Postgres), docker-compose manages everything in one command.
YAML
version: '3.8'
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- JWT_SECRET=your_super_secret_key_here
restart: always
Open your terminal in the project folder and run:
Build and Start: docker-compose up --build
Run in Background: docker-compose up -d
Stop everything: docker-compose down
A Modular API: Controller-Service-Route pattern.
Security: JWT Authentication and Helmet middleware.
Testing: A Postman collection with automated token handling.
Portability: A Docker setup to ship it anywhere.
Smart move. Without validation, your API is like a house with a locked front door (JWT) but wide-open windows. Joi is the gold standard for Node.js validation because it allows you to create a "schema" that acts as a blueprint for incoming data.
If the user sends an age as a string or forgets a required email field, Joi intercepts the request and sends a helpful error message before your logic even touches it.
validations/resourceSchema.js)Instead of writing fifty if/else statements, you define your rules clearly.
const Joi = require('joi');
const resourceSchema = Joi.object({
name: Joi.string().min(3).max(30).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(18).max(100),
tags: Joi.array().items(Joi.string()),
createdAt: Joi.date().default(Date.now)
});
module.exports = { resourceSchema };
This reusable middleware checks the req.body against the schema. If it fails, it returns a 400 Bad Request.
const validate = (schema) => {
return (req, res, next) => {
const { error } = schema.validate(req.body, { abortEarly: false }); // Catch all errors, not just the first one
if (error) {
const errorMessage = error.details.map(detail => detail.message).join(', ');
return res.status(400).json({ success: false, message: errorMessage });
}
next();
};
};
module.exports = validate;
You place this right before your controller. The order of operations is now:
Auth Check → Data Validation → Business Logic.
const express = require('express');
const router = express.Router();
const controller = require('../controllers/resourceController');
const protect = require('../middleware/auth');
const validate = require('../middleware/validate');
const { resourceSchema } = require('../validations/resourceSchema');
// Protected AND Validated route
router.post(
'/',
protect,
validate(resourceSchema),
controller.create
);
module.exports = router;
Scalable: Adding new fields takes 2 seconds in the schema file.
Informative: Users get specific errors like "email" must be a valid email instead of a generic server crash.
Clean: Your controllers stay "skinny" because they only handle valid, authorized data.
| Layer | Tool | Responsibility |
| Server | Express.js | Handling HTTP requests |
| Security | JWT + Helmet | Identification & Header protection |
| Gatekeeper | Joi | Ensuring data integrity |
| Environment | Docker | Consistent deployments |
Documentation is the "face" of your API. Instead of writing a manual that gets outdated the moment you change a variable, we’ll use Swagger (OpenAPI). This creates a live, interactive UI where people can test your endpoints directly from their browser.
You'll need two packages to make this work: swagger-ui-express and swagger-jsdoc.
Bash
npm install swagger-ui-express swagger-jsdoc
swagger.js)Create a configuration file to define your API's metadata.
const swaggerJsdoc = require('swagger-jsdoc');
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'Universal API Skeleton',
version: '1.0.0',
description: 'A professional-grade Node.js API with JWT, Joi, and Docker',
},
servers: [{ url: 'http://localhost:3000' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
}
}
}
},
apis: ['./routes/*.js'], // Path to the API docs
};
module.exports = swaggerJsdoc(options);
Now, in your routes/resourceRoutes.js, you add a comment block above each endpoint. Swagger reads these and builds the UI automatically.
/**
* @openapi
* /api/v1/resources:
* post:
* summary: Create a new resource
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* responses:
* 201:
* description: Created successfully
* 400:
* description: Validation error
*/
router.post('/', protect, validate(resourceSchema), controller.create);
app.js)Finally, mount the documentation route in your main app file.
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./swagger');
// ... other middleware ...
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
console.log('Docs available at http://localhost:3000/api-docs');
Self-Documenting: Your code and your documentation stay in the same file.
Zero Setup for Others: When a new developer joins, they don't need Postman; they just go to /api-docs.
Testing: The "Try it out" button in Swagger lets you send real requests and see real responses instantly.
| Feature | Implementation |
| Logic Layer | Node.js + Express (Controller-Service pattern) |
| Security | JWT + jsonwebtoken |
| Integrity | Joi Validation Schemas |
| DevOps | Docker + Docker-Compose |
| Publicity | Swagger/OpenAPI 3.0 |
You now have a 100% complete, enterprise-ready API skeleton.
Automating your workflow with CI/CD (Continuous Integration / Continuous Deployment) is the "chef's kiss" of modern development. It ensures that every time you git push, your code is tested, your Docker image is built, and it’s shipped to a registry (like Docker Hub or GitHub Container Registry).
We will use GitHub Actions, as it's built right into your repository.
Create a file at .github/workflows/deploy.yml in your project.
YAML
name: CI/CD Pipeline
on:
push:
branches: [ "main" ] # Runs whenever you push to the main branch
pull_request:
branches: [ "main" ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install Dependencies
run: npm ci # "Clean Install" for production/CI
- name: Run Linting/Tests
run: npm test || echo "No tests defined yet"
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Docker Image
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/universal-api:latest
For this to work safely, you must never hardcode your passwords in the YAML file.
Go to your GitHub Repository Settings.
Navigate to Secrets and variables > Actions.
Add two secrets:
DOCKERHUB_USERNAME: Your Docker ID.
DOCKERHUB_TOKEN: A Personal Access Token from Docker Hub.
With this final piece, your workflow looks like this:
Code: You add a new feature or fix a bug in your local environment.
Validate: Your local Joi schemas and Swagger docs ensure quality.
Commit: You push to GitHub.
Automate: GitHub Actions automatically wakes up, installs your dependencies, builds your Docker image, and pushes it to the cloud.
Deploy: Your server (AWS, DigitalOcean, etc.) pulls the new image and restarts.
You've officially built a professional ecosystem from the ground up!
Since you have the backend totally locked down, would you like me to help you create a Frontend React/Next.js "Starter Hook" that connects to this API, handles the JWT storage in local storage, and manages the loading/error states?