Here is the documentation for building a Robust RESTful API with Nextjs
Standards and Best Practices
Last night, while building a single post endpoint with Next.js v12 to mutate the database, I realized how we often skip the standards of RESTful and most of the endpoints we build are really not restful.
RESTful APIs play a key role in facilitating smooth communication between the client (frontend) and the server (backend). As such, it's crucial to design APIs with best practices in mind to ensure maintainability and ease of use for both developers and consumers.
RESTful API Standards and Best Practices When Designing a RESTful API
# Consistent naming conventions: Endpoints should be named consistently and intuitively to make it easy for clients to predict and understand them.
# Use of HTTP methods: REST APIs should use appropriate HTTP methods (GET, POST, PUT, DELETE, etc.) for different operations. For example, use GET to retrieve data, POST to send data, PUT to update data, and DELETE to remove data.
# Stateless interactions: REST APIs should be stateless, meaning each request from the client contains all the information needed to fulfill that request. The server should not store anything about the latest HTTP request a client made.
# Error handling: APIs should return appropriate HTTP status codes and meaningful error messages when an error occurs. Stateless means that every HTTP request happens in complete isolation. In the case you are using JWT, the token is sent to the server with every request, allowing the server to validate the user's authenticity without having to maintain session information
Let's take a look at a concrete example of building a RESTful API endpoint using Next.js, adhering to these standards.
Building a RESTful POST Endpoint with Next.js and next-connect
In the following code, I define a POST
endpoint for creating listings. using next-connect
for handling routes, which is a small Express.js-like router for serverless functions. It allows easy handling of different HTTP methods and middleware, making it a great choice for building RESTful APIs in Next.js.
import nc from "next-connect";
import axios from "axios";
const handler = nc({};
handler.post(async (req,res)=>{
//code will go here
});
export default handler;
But wait what are middleware in APIs?
-lets get technical!
Middleware in the context of API refers to code/functions that get executed between the receiving of a request and the sending of a response in your API server. They are functions that have access to the request object (req), the response object (res), and the next middleware function in the application’s request-response cycle.
Let's break it down further:
- Receiving the request: This is when the API server gets a request from a client (could be a browser, another server, a mobile app, etc.)
- Sending the response: This is when the API server sends back a response to the client.
In between these two steps, you can have multiple middleware functions execute. They can perform a wide variety of tasks like logging, error handling, parsing request bodies, managing authentication, and so on. Which middleware functions get executed for a request is determined by the order in which they are added to the server code, and whether or not they pass along control to the next middleware function.
Here is the code with the addition of a simple logging middleware:
import nc from "next-connect";
import axios from "axios";
const handler = nc();
// middleware that has no mount path. The function is executed for every request to the router
handler.use((req, res, next)=> {
console.log('Request Type:', req.method);
next();
})
handler.post(async (req, res) => {
// your code
});
export default handler;
What happens here is, whenever a POST
request is received, before it is handled by the code in the async (req, res) => {}
function, the logging middleware function is executed. This function prints the request type (in this case 'Post') and then calls next()
which allows the request to proceed to the next middleware function (or the request handler if there is no more middleware).
Remember, the order of middleware loading matters. Middleware functions that load first are also executed first and if a middleware doesn't call next()
within it, those which are loaded after it would not be executed.
While logging is a very basic example, understanding this will allow you to take advantage of next-connect's capabilities and implement more complex middleware like those handling authentications, data validations, error handling, and more. Just remember to always call the next()
function when you are done with your current middleware unless you want to end the request-response cycle there.
I hope the above illustrates the scope of middleware. Let's get back to writing out the Post endpoint.
Authentication, Rate Limiting, and Data Validation in RESTful APIs
- Authenttication: Authentication is a crucial aspect of securing RESTful APIs. It is the process of verifying the identity of a user, process, or system. It involves validating credentials like usernames and passwords to verify the user's identity before granting access to a system or data In a RESTful API, authentication should be centralized in an Identity Provider (IdP), which issues access tokens. These tokens are then used to authenticate requests to the API endpoints. This approach minimizes latency and reduces coupling between services.
In this case, I used a sanity client API token for authentication since I was working with sanity datasets.
import nc from "next-connect";
import sanityClient from '@sanity/client';
const client = sanityClient({
projectId: 'your-project-id',
dataset: 'your-dataset',
token: process.env.NEXT_PUBLIC_SANITY_API_TOKEN,
useCdn: false // `false` to ensure fresh data
});
const handler = nc();
handler.post(async (req, res) => {
const category = await client.fetch('*[_type == "category" && title == $title][0]', { title: 'some-title' });
res.json({ category });
});
export default handler;
Rate Limiting*:* Rate limiting is a technique for preventing API abuse by limiting the number of requests that a client can make to an API within a certain timeframe. It can help prevent denial-of-service attacks and protect sensitive resources.
API keys are often used in conjunction with rate limiting. The API key is required for each request to the protected endpoint, and the server will return a 429 Too Many Requests HTTP response code if requests are coming in too quickly. If the client violates the usage agreement, the API key can be revoked.However, API keys are not foolproof and should not be relied upon as the sole means of protection for sensitive or high-value resources. They are relatively easy to compromise, especially when issued to third-party clients
```javascript // Import dependencies import nc from "next-connect"; import rateLimit from 'express-rate-limit'; import sanityClient from '@sanity/client';
// Set up Sanity client const client = sanityClient({ projectId: 'your-project-id', dataset: 'your-dataset', token: process.env.NEXT_PUBLIC_SANITY_API_TOKEN, useCdn: false //
false
to ensure fresh data });
// Define the rate limiting middleware const limiter = rateLimit({ windowMs: 15 60 1000, // 15 minutes max: 100 // limit each IP to 100 requests per windowMs });
// Define API handler using next-connect const handler = nc();
// Use the rate limiter middleware in next-connect handler handler.use(limiter);
handler.post(async (req, res) => { // Fetch category from Sanity const category = await client.fetch('*[_type == "category" && title == $title][0]', { title: 'some-title' }); res.json({ category }); });
export default handler;
In this code, `windowMs` define the duration for which the requests are checked/remembered and `max` defines the maximum number of connections during `windowMs`.
When a client hits the rate limit, the server responds with a status 429 (Too Many Requests) and a message saying too many requests, please try again later.
2. **Data Validation**: Data validation is another critical aspect of securing RESTful APIs. It involves checking the data sent in API requests to ensure it is in the correct format and within the expected boundaries. This can help prevent various types of attacks, such as SQL injection, cross-site scripting (XSS), and more. In the code below, data validation is implemented within the POST method before creating a new listing. For example, we check the ***req.body.title, req.body.description***, and other required fields are not empty and are of the correct type. If the validation fails, you could return a 400 Bad Request HTTP response code with a meaningful error message.
I usually build most of my RESTful API without this which is bad practice lol
```javascript
import nc from "next-connect";
import sanityClient from '@sanity/client';
import Joi from 'joi';
const client = sanityClient({
projectId: 'your-project-id',
dataset: 'your-dataset',
token: process.env.NEXT_PUBLIC_SANITY_API_TOKEN, // or hardcode the token if it's not a secret
useCdn: false // `false` if you want to ensure fresh data
});
// define a schema for the data to be validated
const schema = Joi.object({
title: Joi.string().required(),
description: Joi.string().required(),
// add other fields as needed
});
const handler = nc();
handler.post(async (req, res) => {
// validate the request body against the schema
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
try {
const category = await client.fetch('*[_type == "category" && title == $title][0]', { title: req.body.title });
res.json({ category });
} catch (err) {
console.error('Oh no, error occured: ', err);
res.status(500).json({ error: 'An error occurred while fetching data from Sanity' });
}
});
export default handler;
In this code, we define a Joi schema for the data to be validated. We require that the title
and description
fields are strings and not empty. You can add other fields to the schema as needed.
In the post
method of the handler, we validate the request body against the schema. If the validation fails, we return a 400 status code in the response along with the validation error message.
If the validation passes, we use the fetch
method of the sanityClient
to make a request to the Sanity API. We're fetching a category with the title provided in the request body. The result is sent back in the response.
If an error occurs while fetching the data, we log the error and send a 500 status code in the response along with an error message.
Building Logic into the POST Method
In our POST method, we build a mutation body to create a new listing. This includes fetching the category document, uploading an image, and constructing the mutation for the new listing.
const categoryDocument = sanityClient.fetch('*[_type == "category" && title ==
$title
][0]', { title: title });
This is key in a RESTful API because it encapsulates the logic for creating a new resource, ensuring that the client only needs to make a single request to the server.
To execute the mutation, we send a POST request to the Sanity API, passing the mutation in the request body and the API token in the Authorization header.
import nc from "next-connect";
import sanityClient from '@sanity/client';
import Joi from 'joi';
import axios from 'axios';
const client = sanityClient({
projectId: 'your-project-id',
dataset: 'your-dataset',
token: process.env.NEXT_PUBLIC_SANITY_API_TOKEN, // or hardcode the token if it's not a secret
useCdn: false // `false` if you want to ensure fresh data
});
// define a schema for the data to be validated
const schema = Joi.object({
title: Joi.string().required(),
description: Joi.string().required(),
// add other fields as needed
});
const handler = nc();
handler.post(async (req, res) => {
// validate the request body against the schema
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
try {
// fetch the category document
const categoryDocument = await client.fetch('*[_type == "category" && title == $title][0]', { title: req.body.title });
// construct the mutation for the new listing
const createMutations = {
create: {
_type: 'listing',
title: req.body.title,
description: req.body.description,
category: {
_type: 'reference',
_ref: categoryDocument._id
}
// add other fields as needed
}
};
// send a POST request to the Sanity API to execute the mutation
const { data } = await axios.post(`https://${client.config().projectId}.api.sanity.io/v1/data/mutate/${client.config().dataset}?returnIds=true`, { mutations: [createMutations] }, { headers: { "Content-type": "application/json", Authorization: `Bearer ${client.config().token}` } });
res.json({ data });
} catch (err) {
console.error('Oh no, error occured: ', err);
res.status(500).json({ error: 'An error occurred while fetching data from Sanity' });
}
});
export default handler;
Signing the Token with JWT and Returning a Signed Product
After creating the listing, we sign a token with the product details using the jsonwebtoken library. This token can then be used to authenticate subsequent requests from the client. We include the signed token and the product details in the response.
import nc from "next-connect";
import sanityClient from '@sanity/client';
import Joi from 'joi';
import axios from 'axios';
import jwt from 'jsonwebtoken';
const client = sanityClient({
projectId: 'your-project-id',
dataset: 'your-dataset',
token: process.env.NEXT_PUBLIC_SANITY_API_TOKEN, // or hardcode the token if it's not a secret
useCdn: false // `false` if you want to ensure fresh data
});
// define a schema for the data to be validated
const schema = Joi.object({
title: Joi.string().required(),
description: Joi.string().required(),
// add other fields as needed
});
// function to sign a token with the product details
const signToken = (product) => {
return jwt.sign(product, process.env.JWT_SECRET, { expiresIn: '1h' });
};
const handler = nc();
handler.post(async (req, res) => {
// validate the request body against the schema
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
try {
// fetch the category document
const categoryDocument = await client.fetch('*[_type == "category" && title == $title][0]', { title: req.body.title });
// construct the mutation for the new listing
const createMutations = {
create: {
_type: 'listing',
title: req.body.title,
description: req.body.description,
category: {
_type: 'reference',
_ref: categoryDocument._id
}
// add other fields as needed
}
};
// send a POST request to the Sanity API to execute the mutation
const { data } = await axios.post(`https://${client.config().projectId}.api.sanity.io/v1/data/mutate/${client.config().dataset}?returnIds=true`, { mutations: [createMutations] }, { headers: { "Content-type": "application/json", Authorization: `Bearer ${client.config().token}` } });
// sign a token with the product details
const token = signToken(data);
res.status(201).json({ ...data, token });
} catch (err) {
console.error('Oh no, error occured: ', err);
res.status(500).json({ error: 'An error occurred while fetching data from Sanity' });
}
});
export default handler;
Robust Error Handling
Robust error handling is crucial in a RESTful API. It ensures that the client receives meaningful error messages and status codes when something goes wrong. In our example, we use a try-catch block to catch any errors that occur during the execution of the POST method. If an error occurs, we log the error and send a response with an appropriate HTTP status code and error message. This provides the client with useful feedback about what went wrong, helping them to diagnose and fix the issue.
These components put together should have you well-equipped to build and handle RESTful APIs following best practices. Implementing these practices consistently can lead not only to a more secure API but also can give clients a more intuitive sense of what the API is doing.
Let's BUILD!