Cache Optimization on NextJS Without Vercel

Semaphore
11 min read2 days ago

--

Have you ever noticed that some websites load really quickly while others take a long time? Caching is a key part of how fast a website loads, and Next.js has powerful built-in features to help you use it well. You can make your app run faster and give users a better experience by optimizing the cache, even if you’re not launching on Vercel while potentially saving on hosting costs. Caching basically saves data that is used a lot so that it can be quickly retrieved. The server can serve a cached version of a page from your Next.js app when a user wants it instead of having to render it from scratch. This translates to:

  • Lightning-fast load times: Pages that have been cached load almost instantly, giving your guests a smooth and responsive experience.
  • Less work for the server: When you serve stored pages, your server has more resources to do other things, which makes your application more scalable

To put it simply, optimizing the cache keeps your Next.js app going at its fastest, which makes users happier and lowers server costs.

Next.js gives you a number of ways to improve your cache in a setting that doesn’t depend on Vercel. It automatically caches statically generated pages, improving performance for frequently accessed parts of your application. You can also control data fetching cache behavior by specifying the duration the browser stores the data, ensuring it stays fresh while optimizing speed. For advanced scenarios, you can build custom caching logic using libraries or techniques like local storage. By utilizing Next.js’ built-in caching mechanisms and data fetching strategies, you can greatly enhance your application’s performance without depending on an external platform like Vercel.

Static Site Generation (SSG)

The practice of assembling and displaying web pages at build time as opposed to on-demand is known as static site generating. With HTML, JavaScript, and CSS among other static files, this produces a set that is prepared for user delivery. SSG is accomplished in Next.js via use of functions like getStaticProps and getStaticPaths. Run during build time, these routines retrieve data and produce the HTML required for every page. You can retrieve data and send it as props to your page with getStaticProps, so the content is ready even before the user requests it.

Benefits of Build Time Caching of Static HTML

Pre-rendering of your information gives SSG a number of benefits:

  • Lightning-Quick Load Times: Your visitors will have a seamless experience as pages load nearly instantaneously because the HTML is already built.
  • Greater Scalability: Because SSG doesn’t have to make content for each request, it lightens the strain on your server. This makes traffic handling by your application more efficient.
  • Improved Search Engine Optimization: Pre-rendered content allows search engines to crawl and index your website faster. SSG may result in lower server costs because of a lesser server load.

Implementation Tips and Best Practices for SSG in Next.js

Now that you understand the core SSG process, here are some key implementation tips to optimize your workflow:

  • Choose the Right Content: Periodically updated material, such product pages or blog posts, is best served by SSG. It works less well with often changing material.
  • Use getStaticProps: This Next.js function is your workhorse during the construction process. Use it to retrieve data and make it available to your page components. Access the source code via github
export async function getStaticProps(context) {
// Fetch data here
const res = await fetch('https://api.example.com/data');
const data = await res.json();
 // Return as props
return {
props: { data },
};
}
  • Dynamic Routes: For dynamic pages, use getStaticPaths and indicate which routes need to be pre-rendered:
export async function getStaticPaths() {
const response = await fetch('https://api.example.com/products');
const products = await response.json();

// Set fallback: false to indicate all pre-defined paths are generated at build time
return {
paths: paths, // Assuming 'paths' is defined elsewhere with product IDs or slugs
fallback: false,
};
}

Incremental Static Regeneration (ISR)

ISR builds on the foundation of SSG. During the build process, Next.js renders your application’s HTML pages with the most recent data. These pre-built pages are subsequently served to users, providing extraordinary speed. While ISR uses pre-rendered pages in the same way that SSG does, it adds dynamism. When a request for a page is received, Next.js examines the cache. If the cached page is fresh (within a defined time frame), it is served immediately. If the cached material is obsolete, Next.js initiates a background re-generation process to retrieve new data and update the HTML content. The user continues to see the cached page while the update occurs in the background, ensuring a smooth experience.

It’s important to understand that ISR doesn’t achieve real-time updates on static pages at runtime. Why? This is because while the page is being regenerated in the background, the user continues to see the cached version. This re-generation can take a few seconds, depending on how you retrieve data. The revalidation window you set determines how fresh the item is. By default, there is no revalidation, but you can choose a time period (for example, 60 seconds) for Next.js to check for updates. This means that the content may be slightly out of date compared to a really real-time system.

How to implement ISR in Next.js applications

Next.js makes implementing ISR straightforward. Here’s a basic example:

export async function getStaticProps() {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
  // Revalidate the data every 60 seconds (can be adjusted)
return {
props: {
data: data,
},
revalidate: 60, // In seconds
};
}

In this example, getStaticProps retrieves and returns data as props. The revalidate parameter controls how frequently Next.js checks for updates in the background. When new data becomes available, the cached page is automatically refreshed. Incremental Static Regeneration allows you to build Next.js applications that are both fast and keep your content fresh. Understanding ISR’s strengths and limits allows you to properly use it to produce a seamless and up-to-date user experience.

Cache-Control Headers

Cache-Control headers are necessary for managing the caching behavior of online resources. They teach browsers and intermediary caches such as CDNs how to handle caching, which can have a substantial impact on a website’s performance and efficiency. They determine how, where, and how long a resource should be cached. They help to reduce bandwidth utilization and server burden, resulting in faster load times. They ensure that consumers receive the most current material without excessive server requests.

Setting Up Cache-Control Headers in Next.js

While Next.js automatically configures some default Cache-Control headers, you may want to change them for specific cases. Here is a summary of the options:

  • During SSG, you can define Cache-Control headers within the getStaticProps function. This approach is ideal for static content with a set expiration time.
export async function getStaticProps() {
// ... fetch data
  return {
props: {
data: data,
},
revalidate: 60, // Optional: revalidate every 60 seconds with ISR
cacheControl: 'public, max-age=3600', // Cache for 1 hour
};
}
  • For API routes, you can set headers directly in the response object.
export default async function handler(req, res) {
const data = await fetch('https://api.example.com/data');

res.status(200).json(data).setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 1 day
}

Best Practices for Cache-Control Headers

  • Differentiate between static content (e.g., blog articles) and dynamic content (e.g., user profiles) and use appropriate headers. Static material can have longer cache periods, whereas dynamic content may need shorter caching or none at all.
  • Set appropriate max-age values based on your content’s update frequency as it specifies how long a resource can be cached before being considered stale
  • Use the immutable directive for really static items that never change (for example, photos or JavaScript files). This informs caches to never re-validate the resource, optimizing performance.
export async function getStaticProps() {
// ... fetch data (for non-immutable content)

return {
props: {
data: data,
},
cacheControl: 'public, immutable', // Never revalidate
};
}
  • Use no-cache and no-store together with appropriate revalidation mechanisms (for example, SSR or ISR) to ensure that users always view the most recent version. However, use caution because they can have a considerable impact on performance if used excessively.
export async function getServerSideProps() {
// ... fetch data (for dynamic content)

return {
props: {
data: data,
},
cacheControl: {
// Revalidate every 60 seconds with ISR (consider appropriate strategy)
maxAge: 60,
noCache: true, // Invalidate cached data but doesn't prevent caching entirely
noStore: true, // Don't store data in any cache (use with caution)
},
};
}

Client-Side Caching

Client-side caching includes storing data obtained from the server on the user’s browser. This cached data can then be utilized for future requests, avoiding the need to retrieve it from the server. Typical client-side caching methods include:

  • Browser Cache (HTTP Cache): Using Cache-Control headers from the server, the browser stores resources in cache on its own. Some of these headers are already handled by Next.js by default.
  • Scripts known as “service workers” operate in the background of a webpage even when it isn’t being used actively. If there is cached data available, they can serve it and intercept network queries.
  • Web Storage: There are two primary choices for web storage offered by the browser:
  • localStorage: Information is kept around even when the browser window is closed.
  • sessionStorage: When a browser window or tab is closed, data is removed.

Implementing Client-Side Caching in Next.js

Here are two approaches to implement client-side caching in your Next.js application:

  1. Service Workers for Granular Caching: Service workers provide you more control over how you cache. To add service worker to your Next.js file, we must first register service worker in a global scope environment and create a service worker file that contains all of our service worker code. You can access the code through GitHubFirst, let’s register a service worker in _app.js by adding the following in the function:
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/service-worker.js')
.then((registration) => console.log('scope is: ', registration.scope));
}
}, []);

We are registering the service worker in _app.js to enable application-wide caching. This ensures that the service worker has access to all resources (pages and assets) in your Next.js project. This allows it to possibly cache data and requests for whatever page the visitor visits. Head over to the public directory and create a new file called service-worker.js. This file serves as the control center for our client-side caching implementation. Paste the following in the file you created

const installEvent = () => {
self.addEventListener('install', () => {
console.log('service worker installed');
});
};
installEvent();const activateEvent = () => {
self.addEventListener('activate', () => {
console.log('service worker activated');
});
};
activateEvent();

After saving service-worker.js, reload the browser page with “Offline” unchecked in DevTools. Enable “Update on reload” to ensure the service worker updates with each page refresh. The status below shows that the service worker is activated and running.

To accomplish efficient client-side caching for the current user’s page, we use the clone() method from the service worker’s fetch event handler. This allows us to cache the entire page’s assets at once. Add the following to your service-worker.jsfile

const cacheName = 'v1';
const cacheClone = async (e) => {
const res = await fetch(e.request);
const resClone = res.clone();
const cache = await caches.open(cacheName);
await cache.put(e.request, resClone);
return res;
};
const fetchEvent = () => {
self.addEventListener('fetch', (e) => {
e.respondWith(
cacheClone(e)
.catch(() => caches.match(e.request))
.then((res) => res)
);
});
};
fetchEvent();

We will have the following in your cache and you can now select the “offline option” and reload the page.

2. Local Storage for Smaller Cached Data in Next.js: You may store key-value pairs of data straight inside the user’s browser using the web storage API localStorage. In contrast to session storage, which disappears when the browser window or tab is closed, this data remains when the browser window is closed. It saves user preferences like as language settings, themes, and accessibility options to ensure a tailored experience across sessions. Temporarily save partially completed forms to avoid users from losing information if they mistakenly navigate away. To keep user login state across page refreshes, it securely store authentication tokens (encrypted properly).Here’s a basic example demonstrating how to use localStorage to cache data in a Next.js component. We will create a custom hook to access the local storage with the following code. You can access the code via Github.

import { useState } from "react";
const useLocalStorage = (key, initialValue) => {
const [state, setState] = useState(() => {
// Initialize the state
try {
const value = window.localStorage.getItem(key);
// Check if the local storage already has any values,
// otherwise initialize it with the passed initialValue
return value ? JSON.parse(value) : initialValue;
} catch (error) {
console.log(error);
}
});
const setValue = (value) => {
try {
// If the passed value is a callback function,
// then call it with the existing state.
const valueToStore = value instanceof Function ? value(state) : value;
window.localStorage.setItem(key, JSON.stringify(valueToStore));
setState(value);
} catch (error) {
console.log(error);
}
};
return [state, setValue];
};
export default useLocalStorage;

In index.js, you can use it as follows:

import useLocalStorage from "@/hooks/useLocalStorage";
import { useState } from "react";
export default function Home() {
// Get the value from local storage if it exists
const [value, setValue] = useLocalStorage("favoriteNumber", "");
// Set the favorite number state using the value from local storage (or the empty string)
const [favoriteNumber, setFavoriteNumber] = useState(value);
// When user submits the form, save the favorite number to the local storage
const saveToLocalStorage = (e) => {
e.preventDefault();
setValue(favoriteNumber);
};
return (
<div>
<label htmlFor="number">Your favorite number</label>
<form onSubmit={saveToLocalStorage}>
<input
id="number"
value={favoriteNumber}
onChange={(e) => setFavoriteNumber(e.target.value)}
/>
<input type="submit" value="Save" />
</form>
</div>
);
}

CDN Integration

Picture a network of servers positioned thoughtfully throughout the globe. Such is a Content Delivery Network (CDN). By integrating a CDN with your Next.js application, you essentially spread your static content — images, JavaScript, and CSS — over these servers. By bringing your material closer to users, loading times are greatly accelerated.

Benefits of CDN Integration

  • Faster Load Times: By serving material from the closest CDN server, users experience is delivered with less latency.
  • Greater Scalability: High traffic spikes can be managed by CDNs without affecting your primary server.
  • Reduced Bandwidth Costs: Your server will be less taxed when static material is offloaded to the CDN, which may result in lower bandwidth bills.
  • Increased Availability: Because CDNs are dispersed geographically, your material is still available even in the event that one server goes down.

How to Integrate a CDN in Next.js Applications

Next.js provides a simple method for CDN connection. This is the broad outline:

  1. Select and configure your CDN provider. Examples of CDN providers are Cloudfare, Fastly all of which offer significant benefit over Vercel. You’ll be given a CDN URL to serve your assets from.
  2. Within your next.config.js file, specify the assetPrefix. Next.js is instructed to add this URL to the paths of your static assets. An illustration setup is this:
module.exports = {
// ... other configurations
assetPrefix: 'https://your-cdn-domain.com/',
};

3. Replace ‘https://your-cdn-domain.com‘ with the URL provided by your CDN and deploy your application to a platform that supports CDN integration. You can get more helpful details here.

Conclusion

Cache optimization in Next.js is a complex technique that greatly improves cost effectiveness as well as performance. Pre-rendering pages at build time with Static Site Generation (SSG) allows developers to guarantee quick delivery and less server load. This method is improved even more by Incremental Static Regeneration (ISR), which permits real-time page updates without compromising the advantages of static generation.

Cache-Control headers must be used strategically in order to control content storage and delivery and to allow developers to customize caching behavior to suit the requirements of their applications. Using Service Workers and localStorage to cache browsers on the client side can dramatically shorten load times and enhance user experience.

Ultimately, connecting with Content Delivery Networks (CDNs) broadens the use of cache optimization by dispersing content over a worldwide network to guarantee quicker access from any area. Next.js’s all-encompassing caching method not only speeds up content delivery but also makes best use of available resources, making it a necessary technique for contemporary web development.

Originally published at https://semaphoreci.com on July 3, 2024.

--

--

Semaphore

Supporting developers with insights and tutorials on delivering good software. · https://semaphoreci.com