A small, correct REST API - then live on the internet with a free hosted database.
This guide builds a notes API - create, list and delete - with Express and PostgreSQL, using patterns that hold up in real projects: a connection pool, parameterised queries, input validation and configuration from environment variables. Then it deploys the API with a free hosted database.
The quickest route is a hosted database, so you don't install anything locally. Create a free project on Neon (Supabase works too), and copy the connection string it gives you. It looks like:
postgresql://user:[email protected]/neondb?sslmode=require
In the provider's SQL editor, create the table:
CREATE TABLE notes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
The CHECK constraint means the database itself refuses empty or oversized notes, even if a bug in your code lets one through.
mkdir notes-api
cd notes-api
npm init -y
npm install express pg
Open package.json and add "type": "module" so you can use import syntax, plus a start script:
{
"type": "module",
"scripts": {
"dev": "node --watch --env-file=.env server.js",
"start": "node server.js"
}
}
(Keep the other fields npm generated; just add these.) Create .env with your connection string, and .gitignore so it never gets committed:
# .env
DATABASE_URL=postgresql://user:[email protected]/neondb?sslmode=require
# .gitignore
node_modules/
.env
Create db.js:
import pg from "pg";
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
});
A pool keeps a few connections open and lends them to requests as needed. Creating a new connection for every request is slow and will exhaust the database's connection limit under load.
Create server.js:
import express from "express";
import { pool } from "./db.js";
const app = express();
app.use(express.json());
app.get("/api/notes", async (req, res) => {
const { rows } = await pool.query(
"SELECT id, body, created_at FROM notes ORDER BY id DESC LIMIT 100",
);
res.json(rows);
});
app.post("/api/notes", async (req, res) => {
const body = typeof req.body?.body === "string" ? req.body.body.trim() : "";
if (body.length < 1 || body.length > 2000) {
return res.status(400).json({ error: "body must be 1-2000 characters" });
}
const { rows } = await pool.query(
"INSERT INTO notes (body) VALUES ($1) RETURNING id, body, created_at",
[body],
);
res.status(201).json(rows[0]);
});
app.delete("/api/notes/:id", async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
return res.status(400).json({ error: "invalid id" });
}
const { rowCount } = await pool.query("DELETE FROM notes WHERE id = $1", [id]);
res.status(rowCount ? 204 : 404).end();
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "something went wrong" });
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`API listening on port ${port}`));
$1 placeholders send values separately from the SQL, so user input can never change the query. Never build SQL with template strings.body and the format of id before touching the database, and returns 400 with a useful message.async handler, Express 5 passes the error to the error middleware at the bottom automatically - older Express 4 tutorials wrap everything in try/catch because v4 didn't.201 for created, 204 for deleted, 404 when there was nothing to delete.npm run dev
--watch restarts the server when you save a file, and --env-file loads .env - both built into modern Node, no extra packages. In another terminal:
curl -X POST http://localhost:3000/api/notes -H "Content-Type: application/json" -d '{"body": "First note"}'
curl http://localhost:3000/api/notes
curl -i -X DELETE http://localhost:3000/api/notes/1
Try sending an empty body or a non-numeric id, and confirm you get a 400, not a crash.
.gitignore keeps .env out).npm install and Start command to npm start.DATABASE_URL with the same connection string.PORT for you, which the code already reads.
Your API is now live at a .onrender.com address. On the free tier it sleeps after a period of inactivity, so the first request after a pause is slow.
cors package and allow your frontend's exact origin.node:test runner plus Supertest can exercise every route.