13.1 API Handling, Server Actions and Middleware
# NextJS API Handling
When building React Apps you are basically creating a SPA. With NextJS we get server-side routing for free. We are building pages, not just components inside one page.
For each folder you have a page.js file. Combined with the parent layout.js this creates the index.html file for the current route. These pages are used on the server side to create the HTML
before it gets sent to the browser.
NextJS - Just Like Express
NextJS apps can be thought of like an Express app. Just like in MAD9124, you are building a server-side application with endpoints.
In NextJS, the endpoints are either a page.js file or a route.js file.
Page.js files return HTML files.
Route.js files can return any file type (but typically return JSON).
You still have reusable components. Inside the app folder you should create a components folder where you can save all the reusable components. These components can be server side ones or you can
add the use client directive on the first line to make them into a component that will be streamed to the browser and rendered with client side JavaScript.
Since your pages are server side script, it means that you can make the fetch calls from the server side too.
If you consider the APIs that you are building in MAD9124, then you will remember that server side fetch calls do NOT have the CORS restrictions that the browsers have. They also can include API
keys because this script never gets sent to the browser. There is no security risk having your API key in the server side JS. With a client side script, including your API key does expose the key to
anyone who visits your website.
With server side fetching, we need no state variables or hooks like useEffect and useState.
Server-side rendering means avoiding CORS issues.
Here is a simple example of how a page that uses fetch would be set up.
// some page.js file as an example
export default async function Page(props) {
let url = `https://some.api.com/example`;
let resp = await fetch(url);
if(!resp.ok) {
//handle the error and tell the user
return <div>{Error message for user}</div>
//throwing an error here without a catch will trigger loading the error.js file.
}else{
//render the content
let data = await resp.json();
return (
<div>
{/* This is the content for your page */}
</div>
)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Props params and searchParams
When you have a dynamic route created by a folder name like [category], the props object passed into the page.js file will include a property object called params, which will contain the value
from the URL as a property named after the folder.
// file - /[category]/.page.js
// rendered from a URL of '/hello'
export default async function Page(props) {
//destructure props to pull out params
const { params } = props;
console.log(params); // server side console output will be {category:'hello'}
}
2
3
4
5
6
7
8
This means we can read the portion of the pathname of the url from props.params.
If you have multiple dynamic route segments like /[category]/[product]/details, then the props.params object will contain the values for both props.params.category and props.params.product.
Sometimes we also want to pass data directly to the page inside a dynamic route. Eg: http://localhost:3000/groceries?id=123 to load the /[category]/page.js file and give it an id param from the
query string. So, in the example from the previous paragraph, category and product were both being passed as URL segment values. Instead of doing that, we can pass the category as a URL segment in
props.params and then the product id can be a value in the querystring. This approach will use the props.searchParams property object.
// page for `http://localhost:3000/groceries?id=123` is /[category]/page.js
export default async function Page(props) {
//destructure props to pull out params (this can be done in the function declaration instead)
const { params, searchParams } = props;
console.log(params); // server side console output will be {category:'groceries'}
console.log(searchParams.id); // server side console output will be 123
}
2
3
4
5
6
7
8
The query string values can be used in fetch calls or other ways to create specific data.
# route.js Files
Another fantastic capability that we get from NextJS is the ability to create API folders that are part of our website.
To create endpoints for your own API, just create a folder and place a route.js files inside the folder. The convention is to add a folder called api inside of the app folder. All your route
folders will go inside the api folder. These route.js files are known as Route Handlers.
Let's say that we want to have an API with the following end points:
https://localhost:3000/api/catshttps://localhost:3000/api/dogs
It will have the following directory structure:
- app
- api
- cats
- route.js
- dogs
- route.js
2
3
4
5
6
Inside each of the route.js files, you can create an exported function named for each HTTP Verb.
route.js Function Names
The names of the functions inside a route.js file can only be:
GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD
// /app/api/cats/route.js
import { cookies, headers } from 'next/headers';
//import cookies to be able to access, get cookie values from the request
//import headers to directly access ALL the headers in the request
// inside any function you can do something like this:
//const cookieStore = cookies()
//const token = cookieStore.get('token')
//const headersList = headers()
//const referer = headersList.get('referer')
//both these can also be accessed from the request object
export async function GET(request) {
//each method function will get passed the request object
const url = new URL(request.url);
const params = new URLSearchParams(url);
//each method should return a response object
//we can do a fetch call to another api from here
let resp = await fetch('some other api url', {
method: 'GET',
headers: {
accept: 'application/json',
'API-Key': process.env.DATA_API_KEY,
//read a value from environment variables for the api key
},
next: { revalidate: 60 }
//we can set the results as valid for 60 seconds
});
//With a route.js file we are building a custom Response object
// this response can be anything... text, xml, json, html, images, etc
return new Response('contents of response', {
headers: { 'content-type': 'text/plain' },
status: 200,
});
}
export async function POST(request) {
//each method function will get passed the request object
//function can be async if you plan to do something asynchronous
const url = new URL(request.url);
const params = new URLSearchParams(url);
//we can do fetch calls from here too
let resp = await fetch('some other api url', {
method: 'POST',
headers: {
accept: 'application/json',
'API-Key': process.env.DATA_API_KEY,
}
body: JSON.stringify({id: 123, name: 'Steve'})
});
if (!resp.ok) {
return new Response('{"code":444, "message":"fail"}', {
headers: {
'content-type': 'application/json',
},
status: 444,
});
}
//extract the json data from the fetch
const data = await resp.json();
//convert or select whatever part you want then return it
return Response.json({ data });
}
export function PATCH(request) {}
export function PUT(request) {}
export function DELETE(request) {}
export function HEAD(request) {}
export function OPTIONS(request) {}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
You can create a function for any HTTP methods you need. They can return any type of response you need. They can return JSON, HTML, XML, text, or even images.
Since this code is all running on the server side, it means that we can also access Environment variables from the server. Notice inside the fetch call in the POST function there is a header
that uses process.env.DATA_API_KEY as its value.
In NodeJS you can use process.env to access any and all environment variables on the server.
// .env file
API_KEY=kj3kjhw3kjh43k4gk4jh32k24h9sd8998sd7f
TMDB_TOKEN=3jy48367373.skjhfskjhsdkjfhskdfjhskdf.kj2hk4h3k4jh3khjf
2
3
In the hidden .env file you can list your API keys and tokens. This file is typically NOT uploaded to your Repo. It is used locally in your dev environment. Then on Vercel, or whatever your hosting
environment is, you will define the ENV variables through the dashboard.
Then in your route.js or page.js files you can read these values.
//route.js or page.js
const APIKEY = process.env.API_KEY;
2
If you are using Vercel or Netlify, then part of the configuration for any project will include creating environment variables to hold things like API keys.
Additionally, all the fetch call results will automatically be cached. Inside the fetch() options object we can add a property called next whose value will be an object with a revalidate
property that holds a number of seconds. This is the length of time to hold the fetch results in the cache, to save processing time and bandwidth.
# Server-Side Redirects
If you ever want to do a redirect on the server-side to a different URL then you can import and use the redirect method like the following.
import { redirect } from 'next/navigation';
export function Page(props) {
redirect('https://nextjs.org/');
}
2
3
4
5
# Custom Paths for Imports
When creating your components or pages, the paths to your imports are relative. They will be different depending on which file is doing the importing.
NextJS has a great solution to simplify your imports.
With the following directory structure, let's say we are building the page.js file inside the /app/[category]/product/ folder.
- app
- components
- Header.js
- [category]
- page.js
- product
- page.js
2
3
4
5
6
7
The standard way for product/page.js to import the Header.js component would be:
import Header from '../../components/Header';
If [category]/page.js were importing the same file, then the import would be:
import Header from '../components/Header';
As your application becomes more complex, the calculating of imports can become even more difficult too. To address this, we have a different import syntax for NextJS that we can use.
import Header from '@/app/components/Header';
The @/app is a direct pointer to the app folder from anywhere in the directory structure.
You can build your own custom import shortcuts by editing the jsconfig.json file.
{
"compilerOptions": {
"paths": {
"@/*": ["./*"],
"@/comp/*": ["./app/components/*"]
}
}
}
2
3
4
5
6
7
8
The preceding example shows how to create a custom path prefix @/comp/ that points to the /app/components/ folder. So, if you want to import a component called navbar.js from inside the
components folder, you would write the import like this:
import NavBar from `@/comp/navbar.js`;
//this path works from anywhere in your app.
2
# Server Actions
Server Actions are a powerful new feature in NextJS. They let you create a server-side function for handling form submissions. The contents of the form will be streamed to the function for processing
as a FormData object. You can picture this like doing a fetch call to POST the FormData to a server-side endpoint. The user stays on the same webpage.
After processing the FormData you will have three options in the server-side function:
- Call
revalidatePathwhich tells the server-side cache that whatever path you give it is no longer valid. The next time the user visits the revalidated path they WILL get a new build of the page. - Call
redirectand do a server-side redirect. This will tell the browser to load a new page in place of the one that contained the form. - Do nothing other than accept and process the data on the server-side.
First, in your <form> you will use an action attribute instead of an onSubmit attribute. The value of the action will be the name of the function inside the page function that contains the
form.
When the handleSubmit server-side function is called, it means that NextJS has bundled everything in your Form as a FormData object and sent it to the server. The FormData object will be extracted
from the Request object and passed to your server-side Server Action function.
You MUST add the use server directive as the first line inside your function.
All Server Action functions MUST be async.
// /something/page.js
import MySubmitButton from '@/app/components/MySubmitButton'; //a client side component
export default function Page() {
async function handleSubmit(formData) {
'use server';
//await fetch or whatever async thing you want
//then tell the current route to update itself
revalidatePath('/something');
}
return (
<form action={handleSubmit}>
<input type="text" placeholder="Your name" />
<MySubmitButton />
</form>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
A common practice with Server Action functions is to place them all inside a file called actions.js inside the /app folder. All the async functions inside of actions.js get exported and then
imported into the page(s) that need them.
# useFormStatus Hook
Since all the forms that are being completed by the user are use client components, it means that you can use React hooks inside the component. A NEW hook that has been added along with Server
Action functions is the useFormStatus hook.
The submit button client-side component can use the useFormStatus hook's pending value to determine the current status of the <form>. This is waiting for the form data to be sent to the server
and a Promise resolved signal back from the server indicating that the server action is complete.
'use client';
import { useFormStatus } from 'react-dom';
export default function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending} className="disabled:bg-zinc-500">
Click Me
</button>
);
}
2
3
4
5
6
7
8
9
10
11
12
With the disabled attribute in the button using the pending value, it will be set as disabled after the first time the user clicks the button. It will not get re-enabled until the result is
streamed back from the server.
Any time you have a simple async task to complete on the server, Server Actions are a quick and easy way to handle this.
Another way that you can manage your server actions is to create a single file at the root called actions.js. Place all your server action functions inside this file and have the use server
directive at the top of the file. With the file marked as a server side script you can import any of the functions into your client components elsewhere in your app.
Here is an example of an actions.js file with a couple server action functions. Remember that action functions must be async.
// /app/actions.js
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function zeroOne(something) {
let num = Math.round(Math.random()); //return 0 or 1
//save the value somewhere that can be retrieved when page.js runs again
//then call revalidatePath or redirect
revalidatePath(`/${something}`);
//revalidatePath means that this path is no longer cached
}
export async function waitZeroOne(something) {
//return 0 or 1 after a 2 second delay
let num = await new Promise((resolve) => setTimeout(() => resolve(Math.round(Math.random())), 2000));
//save the value somewhere that can be retrieved when page.js runs again
// revalidatePath(`/${something}`);
redirect(`/${something}?${num}`);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
The server action functions can call:
revalidatePath()from'next/cache'to clear the cached copy of the path.- or
redirect()from'next/navigation'to send the user to a newpage.js.
Then we import those functions in our client side component with event handling.
// /app/components/MyButton.js
'use client';
import { waitZeroOne } from '@/app/actions';
export default function MyButton({ page }) {
return (
<div className="p-8">
<button onClick={() => waitZeroOne(page)}>click me</button>
</div>
);
}
2
3
4
5
6
7
8
9
10
11
The client side button component needs to be used in a page, of course.
// /app/[something]/page.js
import MyButton from '@/app/components/MyButton';
export default function Page({ params, searchParams }) {
const { something } = params;
const num = searchParams.num;
return (
<div className="p-8">
<h1 className="p-8">Page {something}</h1>
{num && <p>The number is {num}</p>}
<MyButton page={something} />
</div>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
The path part of the url is passed to the button through props. The button calls the server action. Then the server action function has to call revalidate so the generated value can be passed back to the page. In this example, the pathname is passed to the server action function.
Here is a repo with a demo of the Server Actions tied to a form (opens new window). It takes two values from a form and uses one for the folder name and the second as a querystring value.
# Error Handling
Another built-in file that Next.js provides is the error.js file. In each folder (URL segment), if you add an error.js file then it will handle uncaught errors in your code.
What does that mean?
Well, it means that if your code throws a JavaScript error and there is no catch block to capture the error, then the error object will be passed to the error.js file and its default export
function.
The error.js file becomes a replacement for the page.js file. It will be the webpage that the user sees instead of whatever URL is in the location bar of the browser. So, remember to provide
options to the user.
'use client';
//error.js should be a client side component
export default function Error({ error, reset }) {
//error is an actual JS Error Object
//if you put a JSON string into the Error when it gets thrown
//then you can decode it here
const errObj = JSON.parse(error.message);
return (
<div>
<p>{error.message}</p>
<p><Link href="/">Back to Home Page</Link></p>
<p>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try Again
</button>
</div>
)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
The error.js page should have styles to replicate the layout used on other parts of your website.
The reset parameter passed through props to the Error function is a function that will try to re-render the current URL segment.
Now, you can do whatever you like with the error object that gets passed to the error.js file. It can be just a typical Error object with a name and a message property. If you want to write
your code to handle that, fine.
However, there is an opportunity here to provide a lot more information to the error.js page. You can create your own custom object filled with details about the error, and turn that into a JSON
string. Then pass that string to the Error object to be parse inside of error.js.
//version 1 - a basic error object
let err1 = new Error('Just a typical error to be thrown');
throw err1;
//version 2 - an error containing JSON
let myErrorObject = {
code: 1234,
details: 'Failed to fetch the data for the id',
ref: searchParams.get('id'),
httpStatus: response.status,
};
let json = JSON.stringify(myErrorObject);
let err2 = new Error(json);
throw err2;
2
3
4
5
6
7
8
9
10
11
12
13
14
With a JSON string you can put any details that you want about the error into the string. That string can be passed through a generic Error object.
An alternative to this is you could build your own custom error objects. This way you will have error types that you can check for inside of the error page.
Here is a short written tutorial on building your own custom error objects (opens new window)
or if you prefer, a video tutorial about the same thing.
So, you can build and throw errors inside of a Server Action (actions.js) or inside of a page.js file that use this technique.
Inside of a route.js file you are already building a JSON response. So, the JSON that you return can include all the error information and you will use the HTTP Response status code as the flag that
an error has happened.
//route.js
export function GET(request) {
//pretending that an error has happened...
let errobj = {
code: 1234,
details: 'Failed to fetch the data for the id',
ref: searchParams.get('id'),
};
let json = JSON.stringify(errobj);
let resp = new Response(json, {
headers: {
'content-type': 'application/json',
},
status: 475, //custom HTTP status code about there being a problem
});
return resp;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Suspense
The <Suspense fallback=""> component that we discussed in React, works well with NextJS too.
In this following example, there is a dynamic component that will be making a fetch call using an async component function. We want the rest of the page to appear in the browser before the fetch
completes. When completed, we want the contents of the dynamic component to be streamed to the browser and replace the <Suspense>.
// app/page.js
import {Suspense} from 'react';
import TheDynamicDataComponent from '@/app/components/TheDynamicDataComponent';
export default function Page(){
return (
<main>
<header>
<h1>Static Masthead content</h1>
<header>
<section>
<Suspense fallback='<p>Loading...</p>'>
<TheDynamicDataComponent />
</Suspense>
</section>
<footer>
<p>Static footer content</p>
</footer>
</main>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
And here is an example of a the TheDynamicDataComponent.
// /app/components/TheDynamicDataComponent.js
export default async function TheDynamicDataComponent() {
//Note the async
//fake a fetch call
await new Promise((resolve, reject) => {
//wait 3 seconds before continuing
setTimeout(resolve, 3000);
});
return <p>This is the dynamic data that took 3 seconds to build.</p>;
}
2
3
4
5
6
7
8
9
10
11
12
# Middleware
When you build APIs with NodeJS and Express, you are frequently using middleware. These are functions that run as part of a sequence that starts when the HTTP request arrives at the server and finishes with the server sending the response back to the browser.
NextJS also has middleware. These are functions that can run before your page.js.
They are most commonly used when you need to do something like validate a user before sending a response. You can use the middleware to do anything you want though. To create the middleware, simply
add a file called middleware.js inside your app folder and give it a function named middleware and a variable called config.
The middleware function will automatically be called (convention over configuration). It will be passed the request object.
The config variable needs to contain an object with a property called matcher. The matcher property will hold an array with a list of routes to match. For each of these routes, the middleware
function will be called before your page.js.
If you don't have the
configvariable then themiddlewarefunction will run for EVERY request.
The request parameter passed to the middleware function will be a NextRequest object. You need to import the NextRequest and NextResponse objects from next/server. The middleware function
needs to return a call NextResponse.next(), which will trigger the next step in the process of layout.js -> page.js -> components, etc. that ends with sending the response to the browser.
Inside your middleware function you will be able to do server-side redirects to other pages if you want. Just use the NextResponse.redirect() method.
import { NextResponse, NextRequest } from 'next/server';
export function middleware(request) {
const isAuthenticated = false;
if (!isAuthenticated) {
return NextResponse.redirect('/login');
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard', '/account'],
};
2
3
4
5
6
7
8
9
10
11
12
13
14
Inside your middleware function, the idea is that you will be provided with the request parameter (which is a NextRequest object). If the function returns nothing then NextJS will just move on
to building the appropriate end point (layout.js, page.js, route.js, etc).
The HTTP Request object that you receive will have the full URL object, the headers, the cookies which were previously set in the browser, and the querystring. The NextRequest object is a
convenience object that makes it easier to get these property values. It has a property called nextUrl that gives you all the parts of the URL. It has a property called cookies which contains all
the cookies currently set in the browser. It also has an ip property with the IP address for the request.
Additionally, if you are hosting your code on Vercel, NextRequest will also contain an geo object that contains the latitude and longitude for the request.
full reference for NextRequest object (opens new window)
export function middleware(request) {
let path = request.nextUrl.pathname; //the request url path (the part after the origin)
let params = request.nextUrl.searchParams; //the querystring object with get() and has() methods
let origin = request.nextUrl.origin; //the request origin eg: http://localhost:3000
let hasToken = request.cookies.has('token'); //Boolean - does the browser have a cookie called token
let token = request.cookiest.get('token'); //string value of the token cookie
}
2
3
4
5
6
7
8
If you DO return something from the function then it needs to be an initial HTTP Response object, which will be then passed along and have the appropriate endpoint built on top of it.
You create the HTTP Response object with one of two methods:
NextResponse.next()which creates the container that will hold the endpoint requested in therequestparameter.NextResponse.redirect()which will be the container for a new endpoint that you define.
Both of these methods return a Response object that you can put into a variable or you can just return it from the function.
import { NextResponse } from 'next/server';
export function middleware(request) {
let response = NextResponse.next();
response.headers.set('X-myheader', 'Some value');
return response;
//or just...
//return NextResponse.next();
}
2
3
4
5
6
7
8
9
The reason you would put the response object into a variable is so that you can edit things like the headers or cookies that will be returned to the page endpoint.
A common use case for the middleware function would be when you are having a user log in to the website. You receive a JWT token string through the querystring and you need to set the value as a cookie.
To set the cookie you need to add the cookie to Response object headers so it can be passed to the client-side and saved in the browser.
# Cookies
A cookie is just a string that gets passed between client and server as an HTTP header.
When a browser sends an HTTP Request to the server for a resource of the same origin as the current page, and there is currently a cookie set in the browser, then the cookie will be sent as a header.
The JavaScript Request object could look like this:
let req = new Request(url, {
method: 'GET',
headers: {
cookie: 'username=archer;path=/;maxAge=3600;httpOnly;secure',
},
});
2
3
4
5
6
A Request object can have a header called cookie. This will be the contents of the cookie from the browser.
A Response object can have a header called set-cookie. This is an instruction from the server to the browser to save a cookie with those settings.
let resp = new Response('<html><head></head><body>Some web page content</body></html>', {
headers: {
'content-type': 'text/html',
'set-cookie': 'username=archer;path=/;maxAge=3600;httpOnly;secure',
},
});
2
3
4
5
6
If you are writing client-side JavaScript, and you are wanting to create a non-httpOnly cookie, then you can use document.cookie = 'username=archer' to set a cookie in the browser. Then next time
you make a request from the browser the cookie will be sent in the cookie header along with the Request.
When you are using NextJS middleware, you will get a request parameter in the function that you can use to read the value of any cookies.
export function middleware(request) {
console.log(request.cookies.get('username'));
}
2
3
If you want to send a cookie to the browser then you have to do this on the Response object.
import { NextResponse } from 'next/server';
export function middleware(request) {
const response = NextResponse.next();
response.cookies.set('username', 'archer');
return response;
}
2
3
4
5
6
7
Now, the limitation in the above approach is that you do not have any control over the additional cookie settings.
Here is a longer reference to the basic cookie parameters (opens new window).
To get past the limitations of the response.cookies.set method, and to be able to change the various other parameters, then you will need to use the cookies() method imported from next/headers.
The cookies() method can be used inside middleware.js or any other file. However, if you want to use cookies().set(), then you need to use it inside a Server Action function or a route.js file.
So, we import the function from actions.js into middleware.js and then call the function to set the cookie.
// /app/actions.js
import { cookies } from 'next/headers';
export async function setCookie(_name, _value, age) {
cookies().set({
name: _name,
value: _value,
secure: true,
httpOnly: true,
maxAge: age,
path: '/',
});
}
2
3
4
5
6
7
8
9
10
11
12
13
// /middleware.js
import { NextResponse } from 'next/server';
import { setCookie } from '@/app/actions.js';
export function middleware(request) {
const response = NextResponse.next();
setCookie('username', 'archer', 3600);
return response;
}
2
3
4
5
6
7
8
9
# What to do this week
TODO Things to do before next week.
- Read all the content from
Modules 13.1, 13.2, and 14.1. - Keep watching the React in 2021 Video Tutorial Playlist (opens new window)