Serving assets and images in Next.js from a CDN without Vercel

Akash Srivastava

Akash Srivastava

November 28, 2023

NeetoCourse allows you to build and sell courses online. BigBinary Academy runs on NeetoCourse. NeetoCourse uses Next.js to build its pages.

If one uses Vercel to deploy a Next.js application, Vercel automatically configures a global CDN. However, NeetoCourse is hosted at NeetoDeploy, our own app deployment platform. This meant we had to figure out how to serve assets and images from a CDN. We like using Cloudflare for various things and we decided to use Cloudflare as the CDN for this case.

There are two kinds of files in a server-side rendered Next.js app that can be served from a CDN.

  • Assets like server-rendered HTML, CSS, JS, and fonts.
  • Public media like images and videos. These typically reside in the public folder.

Server rendered assets

When Next.js is deployed in production then during the next build step all server rendered assets are put in .next/static/ folder. Next.js provides an option to set a CDN to serve these assets. This can be done by setting the assetPrefix property in next.config.js, where ASSET_HOST is an environment variable that contains the CDN url.

/* next.config.js */
...

const assetHost = process.env.ASSET_HOST;

const nextConfig = {
  ...
  assetPrefix: isPresent(assetHost) ? assetHost : undefined,
  ...
};

module.exports = nextConfig;

The official doc has more details about it.

Result

As we can see in the below pic we had a cache hit. It means the assets are being served from the configured CDN.

Cache HITs on server rendered assets

Public media

Next.js has <Image> component that comes with out-of-the-box necessary optimizations. If we could use <Image> tag then our CDN problem would also be solved. However the <Image> component does not work well with Tailwind CSS, and we rely heavily on Tailwind CSS.

Therefore, we loaded images from the public folder using the traditional <img> tag.

import React from "react";
import dottedBackground from "public/index-dotted-bg";

const HeaderImage = () => (
  <img
    alt="dotted background"
    className="m-0 w-20 md:w-40 lg:w-auto"
    src={dottedBackground}
  />
);

However now images are not optimized by Next.js and are served from the server directly. This is because Next.js does not consider media in public folder as static assets to be sent to CDN.

Configuring CDN

Since we had already set up and configured a CDN using cloudflare, we set up a util to prefix the source with our asset host if it is present in the app environment.

import { isNotNil } from "ramda";

export const prefixed = src =>
  src.startsWith("/") && isNotNil(process.env.ASSET_HOST)
    ? `${process.env.ASSET_HOST}${src}`
    : src;

Now we can use prefixed in the <img> tag.

import React from "react";
import { prefixed } from "utils/media";

const HeaderImage = () => (
  <img
    alt="dotted background"
    className="m-0 w-20 md:w-40 lg:w-auto"
    src={prefixed("/index-dotted-bg.svg")}
  />
);

We expected that with this configuration if ASSET_HOST value was set up then the images would be served from the CDN. Otherwise they would be served from the server.

The images did load correctly, but the CDN was not able to cache them. All of the requests resulted in cache misses.

Cache MISSes on server rendered images

Solution

The CDN was not able to cache the images because the Cache-Control header was not set on the images. This header is set by Next.js when the assets are served from the .next/static/ folder, but not when they are served from the public folder. In such cases, we have to explicitly configure the header to be sent when a request is made from the CDN.

So, we ended up adding a custom headers block to the next.config.js.

/* next.config.js */
...
const nextConfig = {
  ...
  headers: async () => [
    {
      source: "/:all*(.png|.jpg|.jpeg|.gif|.svg)",
      headers: [
        {
          key: "Cache-Control",
          value: "public, max-age=31536000, must-revalidate",
        },
      ],
    },
  ],
  ...
};

module.exports = nextConfig;

This worked. All subsequent requests from the CDN for public images turned into cache hits.

Result

Cache HITs on server rendered images

Conclusion

  • Use assetPrefix option in next.config.js to serve server rendered assets from a CDN.
  • Explicitly set Cache-Control header in next.config.js and ensure image src URLs contain the CDN asset host to serve public images from a CDN.

If this blog was helpful, check out our full blog archive.

Stay up to date with our blogs.

Subscribe to receive email notifications for new blog posts.