The Cheapest Possible Sitecore Next.js Hosting

Ruslan Kovalenko
Ruslan Kovalenko
Cover Image for The Cheapest Possible Sitecore Next.js Hosting

When building non-static websites, we have to consider pricing for many elements like the database, server, and any additional software that is a part of our application like Redis or Memcached. With static website hosting, pricing is more straightforward as you need only one server that will serve your static files to the internet and make the website accessible to others, which makes it typically a more affordable option for small business owners.

One of the great features of Next.js is the ability to export your application as pure HTML, CSS, and JavaScript files, which can be hosted on any static file server, however, some limitations can be found on Next.js official website documentation, like there is no internationalized routing, image optimization, etc.

Nowadays, the process of image optimization is crucial as it helps in improving page load speed, boosts websites SEO ranking and improves user experience.

Regarding Sitecore Next.js sites, Sitecore kindly provides some documentation of preparing your application for static HTML export, however, speaking of images, we still need a running Content Delivery instance for the media library, as static HTML export does not support exporting Sitecore Media Library, which is not our case as we want to build a fully statically site.

Nevertheless, there are some solutions to this problem:

  1. Introducing your custom Image loader
  2. Dianoga - automatic image optimizer for the Sitecore media library
  3. 3rd party modules

We are going to go with the third option to be independent of any data provider.

Let’s take a closer look at a module Next-Image-Export-Optimizer, which optimizes all static images during build in an additional step after the Next.js static export.

Next-Image-Export-Optimizer

Installation and configuration

Firstly, let’s try to install and configure this module on pure Nextjs vanilla site.

  1. Run next-image-export-optimizer in the root directory of a project.
  2. Add some configuration in next.config.js related to optimizer module, according to the documentation
// next.config.js
 module.exports = {
  output: "export",
  images: {
    loader: "custom",
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
  },
  transpilePackages: ["next-image-export-optimizer"],
  env: {
    nextImageExportOptimizer_imageFolderPath: "public/images",
    nextImageExportOptimizer_exportFolderPath: "out",
    nextImageExportOptimizer_quality: "75",
    nextImageExportOptimizer_storePicturesInWEBP: "true",
    nextImageExportOptimizer_exportFolderName: "nextImageExportOptimizer",

    // If you do not want to use blurry placeholder images, then you can set
    // nextImageExportOptimizer_generateAndUseBlurImages to false and pass
    // `placeholder="empty"` to all <ExportedImage> components.
    nextImageExportOptimizer_generateAndUseBlurImages: "true",

    // If you want to cache the remote images, you can set the time to live of the cache in seconds.
    // The default value is 0 seconds.
    nextImageExportOptimizer_remoteImageCacheTTL: "0",
  },
};
  1. Replace export command in package.json
{
  "export": "next build && next-image-export-optimizer"
}
  1. Last step that we need to do according to their documentation is to change the  component to the  component of this library.
import ExportedImage from "next-image-export-optimizer";
 .....
<div className={styles.center}>
     <ExportedImage
            src="pathToYourImageFile"
            alt="Next.js Logo"
            width={180}
            height={37}
            priority
          />
     <ExportedImage
            src="pathToYourImageFile"
            width={180}
            height={37}
            priority/>
</div>
  1. After running export command module will optimize the images and you can see how progressively larger images are loaded.

imgLoading

Pretty easy, isn’t?

Now let’s check how to deal with remote images, as we might store images on some CDN, referencing them from other server, etc.

Challenges with remote images

Fortunately, Next-Image-Export-Optimizer supports remote images. However, to achieve this, following their documentation, we need to specify all remote image urls in a file called remoteOptimizedImages.js in the root directory of a project and the file should export an array of strings containing the urls of the remote images like this:

// remoteOptimizedImages.js
module.exports = [
  "https://example.com/image1.jpg",
  "https://example.com/image2.jpg",
  "https://example.com/image3.jpg",
  // ...
];

So, if you have some remote images on your site, you need manully specify them in a file itself in order for module to download them and optimize during export command.

During the integration of nextjs-image-export-optimizer to our Sitecore solution I found and reported an issue that when Sitecore serving images, we might have some query string get params like h=160&iar=0&w=300&hash=35459BCDA755F252D21AA9B022CC340F remote images are not getting optimized, so we forked their repo, introduced the fix and modified a little bit logic of getting remote images dynamically. So if you going to use module before they introduce a fix, better to specify images without query string get params in src attribute.

This approach can work for some small Next.js sites, but not for CMS-driven. We don’t know the full list of images in advance. And this list will be often changed as content editors will add and modify content.

Tuning Next-Image-Export-Optimizer to fit Sitecore

Static HTML export itself does not support exporting the Sitecore Media Library and we still need a running Content Delivery instance for the media library, let alone image optimization.

So let’s go step by step to achieve image optimization during Sitecore static site export.

  1. Run npm i @exdst/next-image-export-optimizer ****in the root directory of a project.
  2. Add configuration to your next.config.js specifying image sizes, device sizes and other settings related to the module. In the next.config.js file, we need to use the environment variable EXPORT_MODE to instruct Next.js to use i18n and rewrites options only if EXPORT_MODE is set to false. Your file should look like this:
const jssConfig = require('./src/temp/config');
const { getPublicUrl } = require('@sitecore-jss/sitecore-jss-nextjs/utils');
const plugins = require('./src/temp/next-config-plugins') || {};

const publicUrl = getPublicUrl();

/**
 * @type {import('next').NextConfig}
 */
const nextConfig = {
  // Set assetPrefix to our public URL
  assetPrefix: publicUrl,

  // Allow specifying a distinct distDir when concurrently running app in a container
  distDir: process.env.NEXTJS_DIST_DIR || '.next',

  // Make the same PUBLIC_URL available as an environment variable on the client bundle
  images: {
    loader: "custom",
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
  },
  transpilePackages: ["next-image-export-optimizer"],
  env: {
    PUBLIC_URL: publicUrl,
    EXPORT_MODE: true,
    nextImageExportOptimizer_imageFolderPath: "public",
    nextImageExportOptimizer_exportFolderPath: "out",
    nextImageExportOptimizer_quality: "75",
    nextImageExportOptimizer_storePicturesInWEBP: "true",
    nextImageExportOptimizer_exportFolderName: "nextImageExportOptimizer",

    // If you do not want to use blurry placeholder images, then you can set
    // nextImageExportOptimizer_generateAndUseBlurImages to false and pass
    // `placeholder="empty"` to all <ExportedImage> components.
    nextImageExportOptimizer_generateAndUseBlurImages: "true",

    // If you want to cache the remote images, you can set the time to live of the cache in seconds.
    // The default value is 0 seconds.
    nextImageExportOptimizer_remoteImageCacheTTL: "0",
  },

  reactStrictMode: true,
  i18n: !process.env.EXPORT_MODE && {
    // These are all the locales you want to support in your application.
    // These should generally match (or at least be a subset of) those in Sitecore.
    locales: ['en'],
    // This is the locale that will be used when visiting a non-locale
    // prefixed path e.g. `/styleguide`.
    defaultLocale: jssConfig.defaultLanguage,
  },

  // Enable React Strict Mode

  rewrites: !process.env.EXPORT_MODE && (async () => {
    return [
      // API endpoints
      {
        source: '/sitecore/api/:path*',
        destination: `${jssConfig.sitecoreApiHost}/sitecore/api/:path*`,
      },
      // media items
      {
        source: '/-/:path*',
        destination: `${jssConfig.sitecoreApiHost}/-/:path*`,
      },
      // visitor identification
      {
        source: '/layouts/system/:path*',
        destination: `${jssConfig.sitecoreApiHost}/layouts/system/:path*`,
      },
      // healthz check
      {
        source: '/healthz',
        destination: '/api/healthz',
      },
      // rewrite for Sitecore service pages
      {
        source: '/sitecore/service/:path*',
        destination: `${jssConfig.sitecoreApiHost}/sitecore/service/:path*`,
      },
    ];
  })
};

module.exports = () => {
  // Run the base config through any configured plugins
  return Object.values(plugins).reduce((acc, plugin) => plugin(acc), nextConfig);
}
  1. Modify scripts section in package.json file to be able to export static site in connected mode with optimized images:

	"export:connected": "cross-env-shell EXPORT_MODE=true \"npm-run-all --serial bootstrap next:build next:export && next-image-export-optimizer\"",

  1. Introduce our implementation NextImage component, simply importing ExportedImage from our npm package and replacing Image from Next.js to our ExportedImage
import ExportedImage from "@exdst/next-image-export-optimizer";
if (attrs) {
  return <ExportedImage alt="" loader={loader} {...imageProps} />;
}

The whole file will look like this:

import { mediaApi } from '@sitecore-jss/sitecore-jss/media';
import PropTypes from 'prop-types';
import React from 'react';

import {
  getEEMarkup,
  ImageProps,
  ImageField,
  ImageFieldValue,
} from '@sitecore-jss/sitecore-jss-react';
import  {
  ImageLoader,
  ImageLoaderProps,
  ImageProps as NextImageProperties,
} from 'next/image';
import ExportedImage from "@exdst/next-image-export-optimizer";

type NextImageProps = Omit<ImageProps, 'media'> & Partial<NextImageProperties>;

export const sitecoreLoader: ImageLoader = ({ src, width }: ImageLoaderProps): string => {
  const [root, paramString] = src.split('?');
  const params = new URLSearchParams(paramString);
  params.set('mw', width.toString());
  return `${root}?${params}`;
};

export const NextImage: React.FC<NextImageProps> = ({
  editable,
  imageParams,
  field,
  mediaUrlPrefix,
  fill,
  priority,
  ...otherProps
}) => {
  // next handles src and we use a custom loader,
  // throw error if these are present
  if (otherProps.src) {
    throw new Error('Detected src prop. If you wish to use src, use next/image directly.');
  }

  const dynamicMedia = field as ImageField | ImageFieldValue;

  if (
    !field ||
    (!dynamicMedia.editable && !dynamicMedia.value && !(dynamicMedia as ImageFieldValue).src)
  ) {
    return null;
  }

  const imageField = dynamicMedia as ImageField;

  // we likely have an experience editor value, should be a string
  if (editable && imageField.editable) {
    return getEEMarkup(
      imageField,
      imageParams as { [paramName: string]: string | number },
      mediaUrlPrefix as RegExp,
      otherProps as { src: string }
    );
  }

  // some wise-guy/gal is passing in a 'raw' image object value
  const img: ImageFieldValue = (dynamicMedia as ImageFieldValue).src
    ? (field as ImageFieldValue)
    : (dynamicMedia.value as ImageFieldValue);
  if (!img) {
    return null;
  }

  const attrs = {
    ...img,
    ...otherProps,
    fill,
    priority,
    src: mediaApi.updateImageUrl(
      img.src as string,
      imageParams as { [paramName: string]: string | number },
      mediaUrlPrefix as RegExp
    ),
  };

  const imageProps = {
    ...attrs,
    // force replace /media with /jssmedia in src since we _know_ we will be adding a 'mw' query string parameter
    // this is required for Sitecore media API resizing to work properly
    src: mediaApi.replaceMediaUrlPrefix(attrs.src, mediaUrlPrefix as RegExp),
  };

  // Exclude `width`, `height` in case image is responsive, `fill` is used
  if (imageProps.fill) {
    delete imageProps.width;
    delete imageProps.height;
  }

  const loader = (otherProps.loader ? otherProps.loader : sitecoreLoader) as ImageLoader;

  if (attrs) {
    return <ExportedImage alt="" loader={loader} {...imageProps} />;
  }

  return null; // we can't handle the truth
};

NextImage.propTypes = {
  field: PropTypes.oneOfType([
    PropTypes.shape({
      src: PropTypes.string.isRequired,
    }),
    PropTypes.shape({
      value: PropTypes.object,
      editable: PropTypes.string,
    }),
  ]),
  editable: PropTypes.bool,
  mediaUrlPrefix: PropTypes.instanceOf(RegExp),
  imageParams: PropTypes.objectOf(
    PropTypes.oneOfType([PropTypes.number.isRequired, PropTypes.string.isRequired]).isRequired
  ),
};

NextImage.defaultProps = {
  editable: true,
};

NextImage.displayName = 'NextImage';
  1. Replace the import of NextImage from Next.js to our recently created NextImage component in Sitecore image field. In this case we took /src/components/fields/Styleguide-FieldUsage-Image.javascript file and replaced it with our NextImage component.

Replace this

import { NextImage} from '@sitecore-jss/sitecore-jss-nextjs';

To your recently created NextImage

import { NextImage } from 'pathToYour/NextImage';

It seems like we are almost done, we replaced the default implementation with our custom one, but still, we have a problem in that we need to manually specify all remote image urls which is not a solution. Let’s fix that! Navigate to [[...path]].javascript file and take a closer look to getStaticPaths function that is responsible for identifying which paths/pages to fetch data for and getStaticProps function that fetches data at build time for each page (path).

We are going to modify that functions a little bit, so that we can grab image urls during next:export command.

Firstly, lets introduce image-util-fetcher.ts file with two functions fetchImages for fetching images for and removeTempImages that will remove temp images before next static export.

import { join } from 'path';
const fs = require('fs');
import { mediaApi } from '@sitecore-jss/sitecore-jss/media';

const fetchImages = (props) => {

  var layoutDetailsObj = JSON.stringify(props);
  var srcArray = layoutDetailsObj.match(/"(src)":("([^""]+)"|\[[^[]+])/gm);

  if (props.layoutData.sitecore.context.itemPath != undefined) {

    var fileName = props.layoutData.sitecore.context.itemPath.toString().split("/").join("_");

    if (srcArray != null) {
      var fileUrls = [];
      const srcValues = srcArray.map(item => {

        const match = item.match(/"src":"([^"]+)"/);
        return match ? match[1] : null;

      });

      srcValues.forEach(function (srcUrl) {
        
        srcUrl = mediaApi.replaceMediaUrlPrefix(srcUrl);
        console.log(srcUrl);
        var extension = srcUrl.split(/[#?]/)[0].split('.').pop().trim();

        if (["JPG", "JPEG", "WEBP", "PNG", "AVIF", "GIF"].includes(extension.toUpperCase())) {

          fileUrls.push(srcUrl)

        }

      });

      var fileContent = JSON.stringify(fileUrls);
      console.log("writing to imge urls to file");
      fs.writeFileSync(join(__dirname, `../../../temp_img/${fileName}.js`), fileContent, {
        flag: 'w',
      });

    }
  }
}
const removeTempImages = function () {

  const directoryPath = join(__dirname, '../../../temp_img');

  fs.readdir(directoryPath, (err, files) => {
    if (err) throw err;

    for (const file of files) {
      fs.unlink(join(directoryPath, file), (err) => {
        if (err) throw err;
      });
    }
  });
}

const _ = { fetchImages, removeTempImages };

export default _;

Now we going to import our image-util-fetcher.ts into [[...path]].javascript and call our functions.

// This function gets called at build time on server-side.
// It may be called again, on a serverless function, if
// revalidation (or fallback) is enabled and a new request comes in.
export const getStaticProps: GetStaticProps = async (context) => {
  const props = await sitecorePagePropsFactory.create(context);
  imgUtil.default.fetchImages(props);

  // Check if we have a redirect (e.g. custom error page)
  if (props.redirect) {
    return {
      redirect: props.redirect,
    };
  }

  return {
    props,
    // Next.js will attempt to re-generate the page:
    // - When a request comes in
    // - At most once every 5 seconds
    notFound: props.notFound, // Returns custom 404 page with a status code of 404 when true
  };
};
// This function gets called at build and export time to determine
// pages for SSG ("paths", as tokenized array).
export const getStaticPaths: GetStaticPaths = async (context) => {
  // Fallback, along with revalidate in getStaticProps (below),
  // enables Incremental Static Regeneration. This allows us to
  // leave certain (or all) paths empty if desired and static pages
  // will be generated on request (development mode in this example).
  // Alternatively, the entire sitemap could be pre-rendered
  // ahead of time (non-development mode in this example).
  // See https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration

  let paths: StaticPath[] = [];
  let fallback: boolean | 'blocking' = 'blocking';

  if (process.env.NODE_ENV !== 'development' && !process.env.DISABLE_SSG_FETCH) {
    try {
      // Note: Next.js runs export in production mode
      if (process.env.EXPORT_MODE) { imgUtil.default.removeTempImages(); }
      paths = await sitemapFetcher.fetch(context);
    } catch (error) {
      console.log('Error occurred while fetching static paths');
      console.log(error);
    }

    fallback = process.env.EXPORT_MODE ? false : fallback;
  }

  return {
    paths,
    fallback,
  };
};

That’s it. Now let’s try to export our Sitecore site with optimized images via npm run export:connected command.

optimization

Images are downloaded, optimized and placed into the export folder

files

and we got static website with exported and optimized images

markup

Conclusion

The cheapest hosting — is hosting static content. Next.js gives you fully static output only in export mode. But the Next.js export build doesn’t support all Next.js features available for standalone build type, like image optimization. The implementation of NextImage requires you to have some runtime for image optimization. It can be fixed by the usage of a third-party Next-Image-Export-Optimizer module. It allows you to optimize all your images during the build. That is how you can get a fully static and optimized website that can be deployed to any hosting.

Are you interested in using this approach for your Sitecore site to save hosting costs? Contact us for more details!