Protect Preview Pages 50% Faster with NextJS Middleware 🔒

By using middleware to serve different routes to public and authenticated users, you can keep public pages zippy, while letting your team preview in a real environment.

When building ecommerce websites, it's important that the customer experience is fast and reliable. Sometimes we need to preview product pages that aren't publically accessible yet. This can typically be done with server-rendering, which can add unnecessary overhead. In this article, we'll talk about how using NextJS Middleware, we can blend statically rendered pages for public customers, while seeing a "full-fat" server side render for authenticated users.

Overview of existing options

When using NextJS, developers have access to a variety of tools to craft the exact experience they want their user to have.

When we want to render a dynamic product page to a customer, NextJS provides 3 main ways to get that information to them:

  • Client-side Rendering
  • Server-side Rendering
  • Incremental Static Regeneration

In this blog post I won't be talking about Static Site Generation, nor React Server Components, however I do have a historical article on how Concurrent React will help developers integrate them in the future.

I'll talk about the three options, some rules-of-thumbs for when to use each, and then how we can blend their usage without overhead thanks to middleware.

If you want a sneak peek at the code we'll be exploring, take a look at https://nextjs-middleware-product-page.vercel.app/product/red-shoes

A black and white husky dog standing on a beach, looking contemplative.
I chose a husky here to look like a guard dog, safe and fast to protect your content. However, a quick Google suggests "Huskies love people too much to be effective guard dogs." @joepix on Unsplash.

Client Side Rendering

This is fairly standard for modern React applications. You send over an HTML shell, and then on the browser, make fetch requests to various APIs to display data.

We won't talk much about this - typically an ecommerce website is less likely to use this, as it can result in suboptimal SEO, and can be slower for customers with slow internet connections.

Server Side Rendering

Instead of fetching data on the web browser, data is fetched by the same web server that's generating all the HTML. This greatly reduces round-trip latency to data servers and APIs, and the data will be embedded directly in the HTML. This is generally good for SEO and user experience, but it has two downsides:

  1. There might be a noticeable page load delay for the end user. If the web server needs to talk to a database to render the page, this time can add up and be passed to the end user. In our example website, the SSR page takes a whole second generating the page before the first byte is sent to the browser!
  2. It can be wasteful. If your data rarely or never changes, why incur the cost of a database call for every page render?

These downsides are complemented by two powerful upsides however:

  1. The data is always fresh. A stale page can't be stuck in a CDN if you never cache it in the first place!
  2. You can authenticate customers, hiding private data from those without access.
A note on caching

SSR has been the standard for building websites for many years, such as with PHP, ASP.NET, Ruby on Rails, Django or even Remix. Typically these provide caching headers allowing both CDNs and browsers to cache certain web-pages. However, if your goal is to always provide the freshest possible data, caching may be a much more complex system to set up correctly.

Incremental Static Regeneration

ISR is a NextJS concept that lets you access data from the server side, but send the customer a cached webpage instantly.

Based on some stale-timer, ISR will generate fresh pages in the background, but (almost) always quickly serve a cached page to the end-user.

This is great for websites with semi-dynamic data, such as an ecommerce store. Product names, descriptions, and prices are unlikely to change minute to minute, but likely to change day to day. With ISR, our web server only pays the cost of connecting to the database once every refresh (which we could set at say, once every hour) rather than on every customer connection.

This gives us

  • really fast customer page loading
  • dynamic (enough) pages

What's the downside? Well, we can't authenticate users.

Because the data-fetching happens in the background, and users are served cached pages, it necessarily cannot authenticate each user to see if they're allowed to check a private page or not.

Authenticating in middleware

We said that a problem with SSR is that it's too slow, but the problem with ISR is that we can't authenticate. NextJS Middleware lets us solve this problem by choosing a different rendering strategy based on the customer's authentication headers.

Middleware can be used with many hosts (such as Netlify, Cloudflare Workers and more), and in our example we're using Vercel Edge.

Middleware is defined as a Javascript function that runs before your page is delivered to an end-user. It sits inside any of your folders in the /pages folder. It has access to a user's cookies, as well as other information such as geolocation data.

What we return from this function is exactly what the user sees - we could return a direct Response object, like so:

import type { NextFetchEvent, NextRequest } from "next/server";

export function middleware(req: NextRequest, ev: NextFetchEvent) {
  return new Response("Hello, world!");
}

or we could redirect to another webpage:

export function middleware(req: NextRequest) {
  return NextResponse.redirect(`/newpage`);
}

or we can rewrite the URL:

export function middleware(req: NextRequest) {
  const country = req.geo.country?.toLowerCase() || "us";

  if (country === "fr") {
    // Is the user in France?
    // Show them a France specific page
    return NextResponse.rewrite(`${req.nextUrl.pathname}/fr`);
  }

  return NextResponse.next();
}

Rewrite vs Redirect

These two previous examples may seem to be the same, but they have a subtle difference.

A redirect will be visible to the end-user. That is, a customer who is redirected from /oldpage to /newpage will see their browser show mywebsite.com/newpage.

A rewrite happens behind the scenes. In our rewrite example, a French user will see content designed for France, but will still see /oldpage in their web browser.

Rewriting with authentication

Let's tie this all together.

Using Middleware, we can read user cookies. With ISR, we can serve a fast, public-only page, and with SSR we can serve a dynamic, authenticated private page, accessing the database.

We can use Middleware to ask "Does this user look like they might be authenticated?" If no, serve them the fast, cached page. If yes, serve them the slow SSR page - which will do a full authentication.

Imagine we have a product page, at /products/:productId. Here is how we might write that middleware:

import { NextFetchEvent, NextRequest, NextResponse } from "next/server";
import Cookies from "universal-cookie";

import { getProductById } from "../../products";

export async function middleware(req: NextRequest, ev: NextFetchEvent) {
  const cookies = new Cookies(req.headers.get("cookie")).getAll();
  const auth = cookies["Authorization"];

  const productId = req.page.params?.id;

  const isrRewrite = NextResponse.rewrite(`${req.nextUrl.pathname}/isr`);
  const ssrRewrite = NextResponse.rewrite(`${req.nextUrl.pathname}/ssr`);

  if (!auth) {
    // If the user does not have an Authorization header, they are definitely not logged in. If they aren't logged in, quickly serve them the Incrementally Statically Generated webpage.
    return isrRewrite;
  }

  // Only do a more expensive check to the database if we have reason to believe they *might* seller
  if (productId) {
    const product = await getProductById(productId);

    // Alternatively you could make anyone who's logged in as a seller see the SSR page which can do all the database checks, rather than do the database call in the middleware.

    // Your actual authorization code would be more complex than this :)
    if (auth === product?.sellerId) {
      return ssrRewrite;
    }
  }

  // If they're not the seller of this product, fall back to the fast public page.
  return isrRewrite;
}

Won't this be slow like SSR?

One of the listed cons of Server Side Rendering is that it requires talking to the server, which talks to the database, both of which introduce extra layers of latency. This could be anywhere from a few hundred milliseconds, to several entire seconds!

What's different about Middleware?

When deployed with Vercel at least, Middleware runs on workers at the edge, using a cut-down set of Javascript libraries. Rather than spinning up a whole Node instance, it runs V8 directly, allowing startup times reported to be less than 1ms.

When all you're doing is checking a cookie like this, and going straight to the cached web-page if we have no reason to believe they're authenticated, the middleware adds overhead in the 10s of milliseconds based on my own measurements. For example, on my machine, topher.io resolves the Time To First Byte in approximately 30ms from the Vercel Edge Network, whereas the example project in the next section resolves in just 78ms - a difference of 50ms. If your SSR takes 200ms, and Middleware only 100ms, that saves your users 50% of the waiting time!

However, please don't take what I say as gospel though, measure it against your own code and data!

An example project

You're done with the core article now. For a more in-depth exploration, please look at this project.

https://nextjs-middleware-product-page.vercel.app/product/blue-trousers

When you first load the site, you won't be authenticated. As such, all "Public Products" will load instantly from the CDN, and all "Private Products" will show a "Product not found" page, also served from the CDN.

Selecting "Log in as red-company" will set an Authorization cookie for you. This will be passed to the middleware on page request, meaning any products offered by the red-company (such as the private Red Hat) will be generated by SSR. This will take longer to load, but you'll see all the information as if you were a public viewer.

Described by caption.
This Chrome network request shows how loading the Blue Shoes page completed the middleware and sent us the right page in under 100ms.

Why not just have a separate edit page behind a seller authenticated dashboard?

This is certainly something you can do, but that is orthogonal to what this project is trying to achieve. Here the goal is for an authenticated seller to see their product, as if they were public, at the same URL the public might view it at. There are several reasons a company might want this specific functionality, such as if they expect sellers to copy the share URL directly from the browser address bar.

Conclusion

NextJS Middleware when run at the edge is a fantastic piece of functionality. It gives us all the power of traditional Server-Side-Rendered websites, with essentially all the speed of CDNs, but without us having to always choose one or the other.

Where could you use the power of edge middleware in your own applications?