ProNextJS
    Loading
    lesson

    Cached Server Actions in Next.js

    Jack HerringtonJack Herrington

    Next.js server actions can return more than just data– They can actually return UI! This is a really cool feature built into App Router that not everyone knows about.

    In this lesson we'll work through a real world example based on an actual use case where server actions are used to cache portions of a content site.

    Note: this is a very advanced technique and I strongly recommend that you only use it if you really need it!

    Understanding the Problem

    Let's imagine we're building a site like Slashdot. Typically, high-traffic sites use a CDN (Content Delivery Network) to handle a lot of the requests and reduce the load on their servers.

    Here's how it works:

    1. The client makes a request to Slashdot.
    2. The CDN intercepts the request.
    3. If the CDN has the page cached, it returns it.
    4. If not, the CDN forwards the request to the origin server (our Next.js app).
    5. The Next.js server returns the page to the CDN.
    6. The CDN caches the page and returns it to the client.

    This is great because it takes a lot of pressure off of our Next.js server, but it usually works on the whole page level.

    What if we want to cache only a section of a page, like a "Most Discussed" section? That's where server actions come in!

    Server Actions to the Rescue

    Remember how server actions can return UI? We can use that to our advantage.

    For example, we can have server actions like this:

    async function getCounter(start: number) {
      "use server";
      return <Counter start={start} />;
    }
    
    async function getTimer() {
      "use server";
      return <Timer />;
    }
    

    We can return static HTML, a combination of static HTML and client components, or even full client components.

    Continuing with the Slashdot example, let's say we have a client component for the "Most Discussed" section.

    Slashdot most discussed section

    Here's how it can work with a server action:

    1. The client component makes a server action request to get the "Most Discussed" content.
    2. The server action returns the rendered HTML or client components for that section.
    3. The client component renders the received content.

    Now, let's break down how server actions work under the hood.

    How Server Actions Work

    Server actions work by having the client send a POST request to the same route it's on, but with a special header called Next-Action.

    The Next.js server, seeing this header, knows to execute the server action instead of a normal page request. It does the work, gets the content, and sends it back to the client.

    flight data

    What gets sent back to the client is "flight data", essentially a serialized version of the UI that's ready to be hydrated on the client-side.

    Our Goal: Caching with Server Actions

    We're going to combine these concepts to cache server action responses at a more granular level. Here's the plan:

    First, intercept the client's server action request and create a unique hash based on the request (including any arguments passed to the server action). This hash will be used as a key for caching the server action response.

    If the hash is found in the cache, return the cached response. If not, let the request go through to the Next.js server, cache the response, and then return it to the client.

    Normally, CDNs don't cache any POST, PUT, PATCH, or DELETE requests, so there would be special configuration needed to make this work in a real-world scenario.

    Since we don't have a real CDN handy for this example, we'll use a simple Express app to act as our CDN simulator. This simulator will sit between our Next.js app and the client, intercepting and potentially caching server action requests.

    Code Breakdown

    Here's a breakdown of the steps involved in implementing this caching mechanism.

    Basic Next.js App

    The starter app can be found in the repo at 05-cacheable-server-actions/cdn-simulator/starter/.

    The home page component includes a simple Next.js app with a Counter and Timer component:

    // inside cdn-simulator/starter/src/app/page.tsx
    
    import Counter from "./Counter";
    import Timer from "./Timer";
    
    export default function Home() {
      return (
        <main>
          <Counter start={10} />
          <Timer />  
        </main>
      );
    }
    

    Creating Server Actions

    In order to get things working, we'll create server actions for getCounter and getTimer that return the components rendered on the server:

    export default function Home() {
      async function getCounter(start: number) {
        "use server";
        return <Counter start={start} />;
      }
    
      async function getTimer() {
        "use server";
        return <Timer />;
      }
      ...
    

    Create a Client-Side Component

    Let's create a new ClientContentSection component that will take the server actions and invoke them on a useEffect hook with an empty dependency array:

    // inside src/app/ClientContentSection.tsx
    
    "use client";
    import { useState, useEffect } from "react";
    
    export default function ClientContentSection({
      onGetCounter,
      onGetTimer,
    }: {
      onGetCounter: (start: number) => Promise<React.ReactNode>;
      onGetTimer: () => Promise<React.ReactNode>;
    }) {
      const [counter, setCounter] = useState<React.ReactNode>(null);
      const [timer, setTimer] = useState<React.ReactNode>(null);
    
      useEffect(() => {
        (async () => {
          setCounter(await onGetCounter(10));
          setTimer(await onGetTimer());
        })();
      }, []);
    
      return (
        <>
          <div>{timer}</div>
          <div>{counter}</div>
        </>
      );
    }
    

    Now we can update the Home component to use the ClientContentSection:

    // inside src/app/page.tsx
    
    // rest of Home component as before
    return (
      <main>
        <ClientContentSection onGetCounter={getCounter} onGetTimer={getTimer} />
      </main>
    )
    

    Checking in the browser, we can see the components rendering as expected:

    the component renders

    Intercepting Server Actions

    In order to intercept server action requests and handle caching, we'll create a custom hook useCacheableServerAction. We'll patch fetch by decomposing its arguments to get the resource and config objects. We'll look inside of the config object to see if Next-Action is present and it is a POST request. If so, we know it's a server action so we'll hash the request and check if it's in the cache. If it is, we'll return the cached response. If not, we'll fetch the data, cache it, and return it:

    import { useEffect } from "react";
    import sha256 from "crypto-js/sha256";
    
    export function useCacheableServerAction() {
      useEffect(() => {
        const { fetch: originalFetch } = window;
        window.fetch = async (...args) => {
          let [resource, config] = args;
          if (
            // @ts-ignore
            config?.headers?.["Next-Action"] &&
            config.method === "POST" &&
            config.body
          ) {
            // @ts-ignore
            const json = JSON.parse(config.body);
            const hash = await sha256(
              // @ts-ignore
              `${config?.headers?.["Next-Action"]}:${JSON.stringify(json)}`
            );
            resource = `?hash=${hash}`;
          }
          const response = await originalFetch(resource, config);
          return response;
        };
        return () => {
          window.fetch = originalFetch;
        };
      }, []);
    }
    

    Now when we refresh the browser, we can see that a hash is being included with the server action requests:

    hash in devtools

    CDN Simulator

    Now that we know the hashing works, we want to simulate the CDN and proxy all the requests to our Next.js server.

    The Express app that will act as our CDN simulator is in the cdn-simulator directory. It will intercept all requests and cache the responses based on the hash. If there is no hash, it will pass through as normal:

    // cdn-simulator/cdn-simulator.js
    
    const express = require("express");
    const { createProxyMiddleware } = require("http-proxy-middleware");
    
    const NEXTJS_SERVER = "http://localhost:3000";
    
    const proxy = createProxyMiddleware({
      target: NEXTJS_SERVER,
      changeOrigin: true,
      on: {
        proxyReq: (proxyReq, req, res) => {
          proxyReq.setHeader("x-forwarded-host", req.headers.host);
        },
      },
    });
    
    const cache = {};
    
    express()
      .use(express.json())
      .use("/", async (req, res, next) => {
        if (req.query.hash) {
          req.rawBody = "";
          req.setEncoding("utf8");
    
          req.on("data", function (chunk) {
            req.rawBody += chunk;
          });
    
          req.on("end", async function () {
            if (!cache[req.query.hash]) {
              const saResp = await fetch(`${NEXTJS_SERVER}${req.url}`, {
                method: "POST",
                body: req.rawBody,
                headers: {
                  ...req.headers,
                  "x-forwarded-host": req.headers.host,
                },
              });
              cache[req.query.hash] = await saResp.text();
            } else {
              console.log(`Cache HIT: ${req.query.hash}`);
              console.log(cache[req.query.hash]);
            }
            res.setHeader("content-type", "text/x-component");
            res.write(cache[req.query.hash]);
            res.end();
          });
        } else {
          return proxy(req, res, next);
        }
      })
      .listen(4000);
    

    Now when starting up the CDN simulator, we can see the cache hits in the console for the Counter and Time::

    cache hits in the console

    However, for the Next.js App we only see the 200 for the regular page request.

    To really illustrate it, we can turn off the CDN Simulator and the Next.js console will show the server action requests:

    Next.js console with CDN simulator off

    Remember, this is an advanced technique that you shouldn't do unless you really have to, but now you know how!

    Transcript