How to Build a REST API: From Route Design to a Live Deployment

Title slide reading Design, build, and deploy a REST API, with labels for design, data, auth, tests, and deploy

To build a REST API, you design its routes and responses, store its data in a database, implement and secure each endpoint, test it, and deploy it. Doing those stages in the right order keeps each one manageable.

This guide follows a small tasks API from an empty folder to a live service. The example uses Node.js, Express, and PostgreSQL, but the process also applies to other stacks. Each stage explains what to do and why it belongs at that point.

In short
  • Write down your routes, status codes, and formats before you implement anything.
  • Use a relational schema and versioned migrations so every environment has the same database structure.
  • Build one endpoint at a time, then add validation, authentication, and central error handling.
  • Turn your contract into automated tests, including the failure cases.
  • Deploy in a repeatable order, then check responses, logs, and health from outside the API.
  1. 0:00Intro
  2. 0:20Know your goal and what you need
  3. 1:10Design your resources and routes
  4. 2:06Define status codes and formats
  5. 3:02Model your data in a relational schema
  6. 3:56Apply changes with versioned migrations
  7. 4:55Implement your endpoints one at a time
  8. 5:48Validate all input at the door
  9. 6:46Secure routes with authentication
  10. 7:42Handle errors consistently in one place
  11. 8:35Write automated tests for each route
  12. 9:38Deploy the API to production
  13. 10:38Verify responses, logs, and health
  14. 11:38Fix the four most common errors
  15. 12:30Everything from design to deployment
  16. 13:17Small steps, clear contracts, easier backends

Set your goal and check what you need

Before writing any code, agree on the finish line: a small, deployed REST API with data storage, authentication, and tests. The running example is a tasks API, because it is simple enough to finish and still uses every skill you need. A small project also lets you practise the whole path, including the deployment stage.

The system has four parts. An API client sends a request, an auth check confirms who is calling, the tasks API applies the rules, and PostgreSQL stores the data. You don't need to be an expert in the skills below, because each one is applied as it comes up.

  • You can work in one programming language.
  • You understand basic HTTP.
  • You can use Git and the command line.
  • You can read and write simple SQL.

Design your resources, routes, and contract

Start with design, because changing a plan costs far less than changing a running API. List the things your API manages and what clients can do with them. Resources are nouns, such as tasks or users. Avoid routes like /create-task, where the action is hidden in the address, and give each action one clear route instead.

The HTTP method supplies the verb, so the path only needs to name what the action applies to. GET /tasks lists tasks and POST /tasks creates one. GET, PATCH, and DELETE with an id read, update, and remove a single task. This consistency means clients can often guess a route before reading your documentation.

Next, define the contract: what each route accepts and returns. Record it in one place, such as an OpenAPI file or shared document. For example, POST /tasks takes a JSON body with a required string title and answers 201 Created, while GET /tasks/99 answers 404 Not Found if that id doesn't exist. With a clear contract, the frontend and backend can be built and changed independently.

  • Every resource name is a noun.
  • Each route lists its accepted fields, required fields, and types.
  • Field names are the same on every route.
  • Errors share one predictable JSON shape.
# Resource: tasks
GET    /tasks
POST   /tasks
GET    /tasks/:id
PATCH  /tasks/:id
DELETE /tasks/:id

Model your data and apply it with migrations

A relational schema describes your tables, their columns, and how they connect. Most resources become a table, and the columns follow from the formats in your contract. The tasks table has an id as its primary key, a user id that references the users table, a required title, and a done flag that defaults to false.

Keys and constraints protect your data even when application code has a bug. The foreign key means the database refuses a task whose owner doesn't exist. NOT NULL rejects a missing title, whichever code path tried to save it. That makes the schema a last line of defence, not just a description of your storage.

Rather than changing the database by hand, record each change in a versioned migration. Running npx knex migrate:make create_tasks creates a timestamped file for the table definition, and npx knex migrate:latest applies any pending migrations. Laptops and production then reach the same schema through the same ordered files, and a feature's schema change appears in the same pull request as its code.

  • Each task has a primary key and a real owner.
  • Required columns use NOT NULL.
  • The migrations folder is committed to Git.
  • Changes to an applied, shared migration go in a new migration instead.
CREATE TABLE tasks (
  id BIGSERIAL PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id),
  title TEXT NOT NULL,
  done BOOLEAN NOT NULL DEFAULT false
);

Implement one endpoint at a time

With the database ready, write the code behind each route, finishing one endpoint before starting the next. The contract tells you what done looks like, so each endpoint becomes a small, clear piece of work. The read endpoint is a good example: it matches GET /tasks/:id, then asks the database for the row with that id.

If no task exists, the handler sends 404. Otherwise, it returns 200 with the task as JSON. Every request follows the same path: the route matches it, the handler applies your rules, the query reads or writes the row, and PostgreSQL stores the data. The answer then travels back up the same chain to the client.

Keep handlers small enough to read as a short story. Move shared logic, such as database access, into helpers you can test. When a rule changes later, you only need to edit it in one place, rather than finding every route that repeats it.

  • The endpoint returns the status codes promised in the contract.
  • A missing record returns 404, not an empty success.
  • Shared logic lives in testable helpers.
app.get('/tasks/:id', async (req, res) => {
  const task = await db('tasks')
    .where({ id: req.params.id }).first();
  if (!task) return res.sendStatus(404);
  res.status(200).json(task);
});

Validate input and secure protected routes

A working endpoint still trusts whoever calls it, so check every value before your logic or database touches it. If a client posts a task with an empty title, the required-title rule fails, no query runs, and the API returns 400 Bad Request. A validation library keeps these checks short and lets you reuse the rules from your contract.

Treat request bodies, path parameters, query strings, and headers as untrusted, because any client can send anything. Keep using parameterised queries, since validation alone does not stop SQL injection. Useful errors name the failed field and the reason, such as "title is required", so the client can fix the request straight away.

Validation checks what was sent, while authentication confirms who sent it. In Express, write the check once as middleware and apply it to each protected route. A client logs in through POST /login and receives a token. GET /tasks with that token returns 200 OK, while the same request without one returns 401 Unauthorized.

A valid token shouldn't unlock everything, so filter tasks by the caller's user id. That one filter stops people reading someone else's data and closes a very common security hole. Protect credentials as well: hash passwords with a dedicated algorithm such as bcrypt or Argon2, and send credentials and tokens only over HTTPS.

  • An invalid request returns 400 without running a query.
  • Database queries are parameterised.
  • A protected route without a token returns 401.
  • Task queries filter by the caller's user id.
  • Passwords are stored only as hashes.

Handle errors in one central place

Even with validation and authentication in place, things will still go wrong, such as a database that stops responding. Instead of writing error responses inside each route, pass errors to one handler at the end of your app. In Express, this error middleware logs the full error, uses a status of 500 if none was given, and sends a safe JSON message.

A single error shape means clients write their error handling only once. Whichever route fails, they read the same field and rely on the same status code rules. Keep stack traces and SQL messages out of responses, because they help attackers. Your logs keep the detail you need for debugging, while callers see only a safe message.

  • Every failing route returns the same JSON error shape.
  • Unexpected errors default to status 500.
  • Responses contain no stack traces or SQL messages.
  • Full error details appear in your logs.
app.use((err, req, res, next) => {
  logger.error(err);
  const status = err.status ?? 500;
  const msg = err.safeMsg ?? 'Server error';
  res.status(status).json({ error: msg });
});

Write automated tests for every route

Automated tests call your routes the way a client would and compare the answers with your contract. Each promise becomes one small test, so the contract is checked rather than just documented. For the tasks API, npm test confirms that POST /tasks returns 201 with a title and 400 without one, and that GET /tasks returns 401 without a token.

Test failure paths as well as successful ones, because errors are part of the contract. Bad input, missing tokens, and unknown ids are exactly what real clients will eventually send. Use a test database and run your migrations against it, so the tests exercise your real schema, start clean each time, and give you evidence that the API is ready to deploy.

  • Each promise in the contract has a test.
  • Tests cover bad input, missing tokens, and unknown ids.
  • Migrations run against a separate test database.
  • The whole suite runs with one command.
npm test

Deploy the API in a repeatable order

The order of a release stays much the same on any hosting platform: run tests, set variables, run migrations, then start the API. Testing first stops a broken build from shipping. Writing the sequence as a script or pipeline means a release never depends on someone remembering the steps, which also makes each one easier to repeat.

Production variables belong in your host's settings. In this example, DATABASE_URL points at the database, AUTH_SECRET signs your tokens, and NODE_ENV selects production mode. Run migrations before starting the API, so new code never runs against an old schema. Where you can, keep each change backward compatible, because the currently running version may still be using the database during a release.

  • Tests pass before the release continues.
  • Real secret values are not committed to Git.
  • Migrations finish before the new version starts.
  • The release steps are written as a script or pipeline.
# Production environment variables
DATABASE_URL=postgres://...
AUTH_SECRET=set-on-your-host
NODE_ENV=production

Verify the live API and fix common errors

A successful deploy doesn't prove the API works, so check it from the outside after every release. Run curl -i against /health and look for HTTP/2 200 with JSON saying the database is up. Then request /tasks with no token. A 401 and your error message show that authentication is on in production.

Next, read the logs from the same moment. Each test request should appear with its route and status code, with no unexpected errors or connection warnings. Silent logs may mean your traffic isn't reaching this deployment. Point a monitoring tool at the health endpoint as well, and make sure that endpoint checks that the app can reach the database.

Most common deployment failures come down to configuration, so the fix is usually quick once you know where to look. Start with the logs, because most of these errors name the real cause. Change one thing at a time and test again, so you know exactly which change solved the problem. The four symptoms below are the ones to check first.

  • CORS: the browser blocks the call but curl works, so allow the frontend origin in your CORS settings.
  • Database: the connection is refused or times out, so check the URL, credentials, and network access.
  • Environment variables: a value is undefined or the app crashes at startup, so set the variable on the host and restart.
  • Migrations: a table is missing or a migration failed, so read the error, fix it, and re-run.
curl -i https://api.example.com/health
curl -i https://api.example.com/tasks

Key takeaways

  • The contract shapes your schema, and your tests check that its promises still hold.
  • Database constraints protect your data even when application code has a bug.
  • Validation checks what was sent, authentication checks who sent it, and ownership checks limit what they can access.
  • Central error handling gives clients one error shape and keeps internal details in your logs.
  • Run migrations before starting new code, then verify the live API from the outside.
  • When a deployment fails, start with the logs and change one thing at a time.

Frequently asked questions

What order should I follow to build a REST API?

Start by designing routes, status codes, and formats, then model the data and apply it with migrations. Implement endpoints one at a time, adding validation, authentication, and central error handling. Finish with automated tests, deployment, and checks on the live service.

Do I have to use Node.js, Express, and PostgreSQL?

No, any stack can follow this process. Node.js, Express, and PostgreSQL are simply the tools used in the example. The design, migration, and testing approach works the same way elsewhere.

Does input validation prevent SQL injection?

Not on its own. Validation checks that incoming values follow your rules, but you still need parameterised queries when talking to the database. Use both.

Why shouldn't I edit a migration that has already been applied?

Other databases may have already run the original file, so editing it can leave environments with different schemas. Add a new migration instead. Every environment can then apply the same ordered changes.

Why does my API work in curl but fail in the browser?

When the browser blocks a call that works in curl, the likely cause is CORS. Allow your frontend's origin in the API's CORS settings, then test again.

What should a health endpoint check?

It should confirm that the app is running and can reach the database. In this example, a healthy response is HTTP 200 with JSON showing the status as ok and the database as up. Pointing a monitoring tool at it means you can be alerted to outages.

Watch the full video on YouTube →

Souy Soeng

Souy Soeng

Hi there 👋, I’m Soeng Souy (StarCode Kh)
-------------------------------------------
🌱 I’m currently creating a sample Laravel and React Vue Livewire
👯 I’m looking to collaborate on open-source PHP & JavaScript projects
💬 Ask me about Laravel, MySQL, or Flutter
⚡ Fun fact: I love turning ☕️ into code!

Post a Comment

CAN FEEDBACK
Ad