How to implement better APIs
The Next.js reference implementation of my recent blog post
Table of contents
- TL;DR
- Walk-through Video
- Preface
- How does it work?
- 1. Be consistent
- 2. Use ISO 8601 UTC dates
- 3. Make an exception for public endpoints
- 4. Provide a health check endpoint
- 5. Version the API
- 6. Accept API key authentication
- 7. Use reasonable HTTP status codes
- 8. Use reasonable HTTP methods
- 9. Use self-explanatory, simple names
- 10. Use standardized error responses
- 11. Return created resources upon POST
- 12. Prefer PATCH over PUT
- 13. Be as specific as possible
- 14. Use pagination
- 15. Allow expanding resources
TL;DR
I created "reference implementation" based on my recent post on How to design better APIs. I tried my best to practically implement all tips mentioned in the article. It's built with Next.js. Check it out here.
Walk-through Video
Preface
On March 3rd 2022, I published an article on How to design better APIs on my Hashnode blog. For some reason, it got quite popular — by the time of writing this, it has over 325 reactions, 34 comments (including my answers) and 112k+ views. It even made it to the front page of Hacker News on March 12th, which is kind of a big deal for me.
What I’m even more happy about, however, is that so many people reached out to me, said that the post was helpful and that it really provided value to them. I appreciate that a lot and I’ll do my best to continue creating useful content in the future, so thank you for reading this. 🙂
Since the tips mentioned in the post were meant to be „language-agnostic“, a couple of folks also asked if there is a project where they can look up an actual real-world implementation. That’s when I thought it would be a nice idea to complement the blog post with an actual project where most of the tips are applied in practice. So here it is! The API is, of course, overly simplistic (no database, no real authentication), but you should get an idea of all the concepts.
How does it work?
The project is built with Next.js API Routes and TypeScript. I created one /users
resource and tried to compress all 15 tips (including pagination, error handling, ...) into a few routes. You can have a look at the repository and the entire source code on my GitHub account. Clone it here. If you have any questions, just shoot me a DM on Twitter. I'm always happy to help and discuss.
The project is basically structured like this:
pages/api/v1*
— The APImodels/*
— Request and response typesservices/*
— Fake data services
In order to keep everything focused on the API itself, I didn't use a real database. Instead, I "faked" the data access logic and used services that return test data.
Following, I will repeat the original blog post's tips and add detailed information on how they're implemented. You can click most of the headers and jump right into the code on GitHub.
There is also a Postman collection checked into the project, where you can play with all API routes.
1. Be consistent
I use the same naming conventions, resource names, HTTP methods and status codes across the entire project.
snake_case
naming in requests and responsesPOST
for creatingGET
for readingPATCH
for updatingDELETE
for deleting200
for success201
for creation400
for client errors401
for unauthorized404
for not found405
for method not allowed
2. Use ISO 8601 UTC dates
I use ISO 8601 UTC dates (more precisely, the specific RFC 3339 format, providing date and time).
const now = new Date()
return {
id,
name,
email,
created_at: now.toISOString(),
modified_at: now.toISOString()
}
3. Make an exception for public endpoints
I added a middleware that requires an API key to be present for each API route.
import { NextResponse, NextRequest } from 'next/server'
import { unauthorized } from 'models/error'
export async function middleware(req: NextRequest, res: NextResponse) {
const { pathname } = req.nextUrl
if (pathname.startsWith('/api') && !req.headers.get('Api-Key')) {
return new Response(JSON.stringify(unauthorized), {
status: 401, headers: {
'Content-Type': 'application/json'
}
})
}
return NextResponse.next()
}
4. Provide a health check endpoint
I added a GET /api/v1/status
endpoint that always returns 200
.
export default function handler(req: NextApiRequest, res: NextApiResponse) {
// GET /api/v1/status
if (req.method === 'GET') {
res.status(200).end()
}
res.status(405).end()
}
5. Version the API
All API routes live inside the v1 folder, which automatically adds the /v1
version to every resource within that folder. I also added a /v2
version of the status endpoint to give you an idea on how another version would look like.
6. Accept API key authentication
The middleware is used to determine the presence of an Api-Key
HTTP header which could then be used to fetch an account with the given API key from the database. In our case, we only check whether this header is provided, otherwise the API will return a 401.
7. Use reasonable HTTP status codes
I'm using a very small set of HTTP status codes (200, 201, 400, 401, 404, 405). See #1 for details.
8. Use reasonable HTTP methods
I'm using a very small set of HTTP methods (POST
, GET
, PATCH
, DELETE
). See #1 for details.
9. Use self-explanatory, simple names
All endpoints and names are self-explanatory.
POST /users
GET /users
GET /users/{id}
PATCH /users/{id}
DELETE /users/{id}
10. Use standardized error responses
I added an error response type that is always used when something wrong happens (4xx).
export type Error = {
code: string
message: string
}
11. Return created resources upon POST
After a resource is created, the most recent "snapshot" of that object is returned from the API.
12. Prefer PATCH over PUT
I don't use any PUT
requests. Updates are always designed around PATCH
.
13. Be as specific as possible
All endpoints are limited to the essentials and don't do anything unexpected.
14. Use pagination
I added a Paged
type that wraps GET requests into a standardized paginated response.
15. Allow expanding resources
It's possible to load orders
when fetching users using the expand
query parameter.