Table of contents
With the release of Next.js 12, Vercel Edge Functions have been announced, allowing for super speedy edge-optimised functions. They can also be used as helpful Next.js middleware functions. In this article I'll explain what they are & how to use them, before diving into a few examples.
What are they?
Vercel edge functions are a kind of serverless function, similar to Vercel's API routes, except they're deployed in CDNs around the world, enabling much quicker connection times.Within Next.js 12 they can be used as middleware—functions that run when users first connect to your website, and before the page loads. These functions can then be used to redirect, block, authenticate, filter, and so much more.
How to use them
In Next.js 12.2 place
a single file named middleware.ts
within the root directory of your project (next to package.json
):
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware (request: NextRequest) {
return NextResponse.next()
}
This function will run before every page, API route,
and file on your website starts to load.
If NextResponse.next()
is returned, or if there is no return value, pages will load as expected, as if there's no middleware:
/pages/index.ts
Within this function we can run our tasks—but we'll get into that later.
Return a response
In previous versions of Next.js, middleware could return a Response
body, but this is no longer allowed.
import type { NextRequest } from 'next/server'
export function middleware (request: NextRequest) {
return new Response('Hello world!')
}
Targeting certain pages
There are two different ways to target a particular page or file with middleware; using config
, or by manually matching the URL.
The following middleware function will only run on the /about
page, or on any paths beginning with /articles
:
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// Runs only on matched pages, because of config
export function middleware (request: NextRequest) {
// Runs for '/about' and pages starting with '/articles'
}
export const config = {
matcher: ['/about', '/articles/:path*']
}
This can also be implemented without a matcher, though bear in mind that this will invoke middleware on every page load (because there are no matched pages):
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// Runs on every page
export function middleware (request: NextRequest) {
if (request.nextUrl.pathname === '/about') {
// Runs for '/about'
}
if (request.nextUrl.pathname.startsWith('/articles')) {
// Runs for '/articles'
}
}
In this article we'll be combining both methods (where necessary), for the most performant and reusable middleware.
Before we start
We'll be using the URL interface in this article,
so it's probably best you get acquainted with it (particularly hostname
and pathname
):
URL {
hash: '#about',
host: 'beta.example.com:8080',
hostname: 'beta.example.com',
href: 'https://beta.example.com:8080/people?name=chris#about',
origin: 'https://beta.example.com:8080',
password: '',
pathname: '/people',
port: '8080',
protocol: 'https:',
search: '?name=chris',
searchParams: {
name: 'chris'
},
username: ''
}
searchParams
is a JavaScript map, not an object.Right, let's take a look at some examples!
Redirecting pages
Redirects (surprisingly!) allow you to redirect from one page to another. In this example
we're redirecting from /2019
to /2022
.
Only absolute URLs work with redirect
and rewrite
, in the form of a string
or a URL
. In this article, we'll mostly be cloning request.nextUrl
(the URL
object for the current request) and modifying it,
instead of creating a new URL
:
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// Beware loops when redirecting to the same directory
export function middleware (request: NextRequest) {
if (request.nextUrl.pathname === '/2019')
const url = request.nextUrl.clone()
url.pathname = '/2022'
return NextResponse.redirect(url)
}
}
export const config = {
matcher: ['/2019']
}
By cloning nextUrl
in this way, we can preserve any parts of the original URL that aren't being changed, such as query strings, or subdomains, and only modify what we need.
Rewriting pages
Rewrites allow you serve a page at one location, whilst displaying the URL of another.
They're handy for tidying up messy URLs, or for using subdomains to separate different sections
within the same website.
In this example, we're rewriting beta.example.com
to example.com/beta
:
/pages/beta/start.ts
Here we're checking for visits on the hostname
beta.example.com
,
and then serving example.com/beta
instead. We're doing this by once again modifying a cloned nextUrl
, changing the hostname,
and adding /beta
to the start of the current pathname.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware (request: NextRequest) {
const hostname = request.headers.get('host')
// If on beta.example.com, redirect to example.com/beta
if (hostname === 'beta.example.com') {
const url = request.nextUrl.clone()
url.hostname = 'example.com'
url.pathname = '/beta' + url.pathname
return NextResponse.rewrite(url)
}
}
In retaining the old url.pathname
, we're making sure that all beta pages
redirect, not just the index.
User agent checking
Next.js 12.2 provides a new userAgent
feature that returns the connected client's user agent allowing us to,
among other things, detect mobile devices:
If we put our previous knowledge together we can now redirect mobile users from example.com
to m.example.com
, and then rewrite m.example.com
to /pages/mobile/
:
import { NextResponse, userAgent } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware (request: NextRequest) {
const hostname = request.headers.get('host')
const { device } = userAgent(request)
// If example.com visited from mobile, redirect to m.example.com
if (hostname === 'example.com' && device.type === 'mobile') {
const url = request.nextUrl.clone()
url.hostname = 'm.example.com'
return NextResponse.redirect(url)
}
// If m.example.com visited, rewrite to /pages/mobile
if (hostname === 'm.example.com') {
const url = request.nextUrl.clone()
url.pathname = '/mobile' + url.pathname
return NextResponse.rewrite(url)
}
return NextResponse.next()
}
Prevent access
Preventing access to files and directories is very simple with edge functions. In this example,
all API routes are blocked unless a custom secret-key
header is passed:
All we're doing here is checking the secret-key
header for the correct value,
and redirecting to a custom error page if it isn't used:
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const secretKey = 'artichoke'
export function middleware (request: NextRequest) {
if (request.nextUrl.pathname === '/api/query') {
const headerKey = request.headers.get('secret-key')
// If secret keys match, allow access
if (headerKey === secretKey) {
return NextResponse.next()
}
// Otherwise, redirect to your custom error page
const url = request.nextUrl.clone()
url.pathname = '/unauthorised'
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/api/query']
}
This API can then be accessed using fetch:
const result = await fetch('https://example.com/api/query', {
headers: {
'secret-key': 'artichoke'
}
})
View counter
Edge functions are a reliable place to keep track of website views; no JavaScript has to be loaded by the page for them to run, unlike with client API calls.
First we check to see whether a page is being accessed—we don't want this to run when APIs are called or files are loaded—and continue if so.
We're making use of event.waitUntil()
here, because it will allow us to run asynchronous code after the
rest of the function has completed. Put simply, we won't add any additional delay by the database call because
we'll process it after the user has begun loading the page.
import { NextResponse } from 'next/server'
import type { NextFetchEvent, NextRequest } from 'next/server'
export function middleware (request: NextRequest, event: NextFetchEvent) {
const { pathname } = request.nextUrl
// Ignore files and API calls
if (pathname.includes('.') || pathname.startsWith('/api')) {
return NextResponse.next()
}
event.waitUntil(
(async () => {
// Add view to your database
// ...
})()
)
return NextResponse.next()
}
My website uses Upstash to keep track of views, a low-latency serverless database for Redis.
Advanced waitUntil() explanation
<OverlyComplexExplanation>
Cloudflare workers implement the service worker API. The second parameter for middleware()
is an ExtendableEvent
from this api, which provides the waitUntil() method. waitUntil()
accepts a Promise
parameter, and is essentially used to prevent computation from closing whilst the promise is unresolved. This is only necessary in service workers.
Promises are returned from async functions, which is why we're passing one as an argument for waitUntil()
, and then immediately executing it. This means that any asynchronous code run within this function, will be run after middleware()
has returned a value.
event.waitUntil(
(async () => {
// ...
})()
)
However, bear in mind that any synchronous code will run before middleware()
has completed, and only code after the await
keyword will run after:
console.log(1)
event.waitUntil(
(async () => {
// ...
console.log(2)
await Promise.resolve()
console.log(4)
})()
)
console.log(3)
1 2 3 4
This probably won't be necessary, but if you need all your code to run after middleware()
has returned a value, place it behind await Promise.resolve()
like console.log(4)
above. Alternatively, setTimeout
within a new Promise
will work too:
console.log(1)
event.waitUntil(new Promise(resolve => {
setTimeout(async () => {
// ...
console.log(3)
await Promise.resolve()
console.log(4)
resolve()
})
}))
console.log(2)
1 2 3 4
</OverlyComplexExplanation>
If you'd be interested in an article fully explaining JavaScript timing (with interactive visualisations), let me know on Twitter.
Location filtering
Edge functions allow you to detect the location of the user's request, and make adjustments accordingly. In this example we're serving the regular website, unless a Danish user connects, where we'll serve a custom error page instead.
The request.geo
object contains
the two-letter country code we
need. If the country code matches, we can then redirect to a custom error page:
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware (request: NextRequest) {
// request.geo.country is undefined in dev mode, 'US' as backup
const country = request.geo.country || 'US'
// If visited from Denmark
if (country === 'DK') {
const url = request.nextUrl.clone()
url.pathname = '/forbidden'
return NextResponse.redirect(url)
}
}
Set theme by sunlight
In another post I wrote about detecting the user's sunlight levels, and setting the theme to dark or light mode accordingly. Vercel Edge Functions give us access to the approximate location of our users, so we can use this to set a theme before the page has even loaded.
Here we're setting the current theme as a cookie, which we'll then retrieve client-side. We're also checking to see if the theme is already set, and skipping the function if it is.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import SunCalc from 'suncalc'
export function middleware (request: NextRequest) {
// Skip if theme already set
if (request.cookies.get('theme')) {
return NextResponse.next()
}
// Get location
const { longitude = 0, latitude = 0 } = request.geo
// Get theme (explanation in related article below)
const now = new Date()
const { sunrise, sunset } = SunCalc.getTimes(now, longitude, latitude)
let mode = ''
if (now < sunrise || now > sunset) {
mode = 'dark'
} else {
mode = 'light'
}
// Set cookie and continue
const response = new NextResponse()
response.cookies.set('theme', mode)
return response
}
Using the new URL import feature in Next.js 12 we can use js-cookie
directly
from Unpkg to get the cookie on the front end,
(not necessary, just a new feature!), and then enable the correct theme:
import Cookies from 'https://unpkg.com/js-cookie@3.0.1/dist/js.cookie.min.js'
// 'dark' or 'light'
const theme = Cookies.get('theme')
// Enable theme
// ...
More examples
Next.js middleware is handy for a number of functions, and I've only touched on the most basic of examples here! Check out the Vercel team's range of demos on GitHub, to see examples of authentication, i18n, A/B testing, and more.