Use s3 as a fallback to serve static assets and resources while using NextJS

Semih Sogutlu
6 min readDec 30, 2019

--

Photo by Hannes Egler on Unsplash

There are only two hard things in Computer Science: cache invalidation and naming things.

— Phil Karlton

From the dawn (ok; maybe I am exaggerating a little :) of the internet, websites have depended upon external static assets and resources. First, websites used images and some markup files like this one (one of my faves!). And then JavaScript and CCS came into play. Over time, capabilities of browsers and hardware evolved significantly, and, with the help of cloud computing, we’re able today to build more complex front-ends than ever! (What does “building better than ever” mean in the context of technology? I don’t even know…)

Fast-forward to today: our main concern has gone from serving static resources to the end user to the speed with which we can serve them. Because humans are highly impatient creatures (which I believe is a good thing), the bounce rate becomes really high when the website you land on does not render something meaningful within two to three seconds.

Well, desperate times call for desperate measures, right? So lots of developers around the world have tried to solve this loading speed problem to keep the users on the website. There are many layers to the solution — for example, there are a lot of image lazy loading libraries out there which rely on some cool stuff that browsers have started to support, but we’re not going to talk about that today. :)

One of the most efficient ways to solve the problem is to rely on CDNs.

Since I am in the content business, it’s really important to me to deliver what you need to read as fast as possible — so I rely on CDNs heavily.

After CI/CD became largely popular, cache busting became an increasingly important problem. Today, I am going to talk a problem that especially affects modern JavaScript frameworks/libraries like NextJS, React, VueJs etc.

So What?

Let’s think about this case… You created a build and you are using NextJS. Webpack splits the code for optimal loading time and then hashes the file names so your JS files look something like this:

yourjsfile.[Hash#1].js

The file above is imported at the head section like this:

<link rel=”preload” href=“../yourjsfile.[Hash#1].js” as=”script”>

So, when the first user hits your website, CDN automatically caches the response from your cluster. That’s great so far! Works perfectly fine!

Then another developer wrote a bunch of code and created a new build, and Webpack created new hashes (let’s say yourjsfile.[Hash#2].js) and your build folder wiped out clean as a result of your build process. Your responses are still cached in CDN, so you won’t see the changes you pushed to production immediately. You have to either explicitly purge the cache or wait however many minutes/days/months for CDN to follow the rules of cache invalidation that you’ve already configured.

Therefore, your yourjsfile.[Hash#1].js is not in your cluster anymore, but it’s still in the CDN.

So here’s the million-dollar problem… What if the cache on your JavaScript file expires before DOM response of your server expires?

DOM will still try to use your yourjsfile.[Hash#1].js file since it’s still linked in the head section, but since your build folder is wiped out, the request will return a 404 and your page will look a dumpster fire.

How do you solve this problem? You can choose not to clear your build folder, but that choice might be costly because the size of your cluster is going to get bigger every time you build/deploy something. That means you need to delete the files on a regular basis. Depending on your build setup, it might not be that easy to figure out which files are in use and which files are not — and that’s because when you build a NextJS project, it looks like this:

so readable, so good!

In this case, storage solutions like s3 come in handy. :)

Great! But why aren’t we directly serving all the assets from s3 and using s3 as a fallback?

Serving assets from s3 is actually pretty slow. In order to serve files from s3, you need to use a CDN. That means s3 needs to be behind CloudFront which means extra cost.

So, Let’s get to the work:

[1] Setting up your distrubution folder in next.config.js file

NextJS automatically defines a distribution directory called “.next” and it proxies it to _next. I need to control how I serve static files, so I am going to use ExpressJS to solve this problem. I’ll give the directory and the proxy the same name. This is very easy to do with next.config.js file:

let nextConfig = {
// some configuration
...
distDir: '_next',
...
// more awesome configuration
}

[2] Serving static files with ExpressJS Middleware

ExpressJS is pretty straightforward: You can actually use “static” function. The code looks as follows:

import express from 'express';const staticAssetsFromLocal =
express.static(
path.resolve(__dirname, '../../'),
{
fallthrough: true,
maxAge: '365d'
}
);

[3] Using s3 as a fallback for static files

It’s also pretty straightforward to serve files from s3 (you can check this NPM package). After setting up an s3 account and the bucket in s3, we can serve the files from s3 with the code as follows:

import S3 from 'aws-sdk/clients/s3';const staticAssetsFromS3 = (req: any, res: any, nextCallback: any) => {

const fileRegex = /(?:.+\/)(.+)/;
const staticAssetRegex = /.*?.js$|.*?.css$|.*?.css.map$/;

//if the file is not .js or .css return the callback
if (req.method !== 'GET' || staticAssetRegex.test(req.url) === false) {
return nextCallback();
}

const filename = req.url.match(fileRegex)[1];
// if the file is hot loaded return callback
if (filename.indexOf('hot-') > -1) {
return nextCallback();
}
console.log(`Entered S3 request match for ${filename}`);
console.log(`bucket ${process.env.S3_BUCKET}`);

const s3Context = new S3({
region: //YOUR S3 REGION,
accessKeyId: //YOUR S3 ACCESS KEY ID,
secretAccessKey: //YOUR S3 SECRET ACCESS KEY,
});

const key = `${//YOUR S3 FOLDER}/${filename}`;

console.log(`Requesting S3 fallback asset for key: ${key}`);

s3Context.getObject({
Bucket: //YOUR S3 BUCKET,
Key: key,
})
.on('httpHeaders', (statusCode: any, headers: any) => {
// if the status is success then set the necessary headers
// to the response
// More info on Etag -> here
if (statusCode < 300) {
res.set('Cache-Control', 'public, max-age=31536000');
res.set('Accept-Ranges', headers['accept-ranges']);
res.set('Content-Length', headers['content-length']);
res.set('Content-Type', headers['content-type']);
res.set('ETag', headers.etag);
}
})
.createReadStream()
.on('error', (err: any) => {
console.error(err);
nextCallback();
})
.on('end', () => console.log(`Served by S3: ${key}`))
.pipe(res);
};

[4] Put all this stuff to work

Since you actually have full control of the request response lifecycle in ExpressJS middleware, (more on middlewares here) it’s pretty easy to combine these two functions:

import next from 'next';
import express from 'express';
const nextApp = next({ dev });
nextApp.prepare().then(() => {
const app = express();
app.use(staticAssetsFromLocal);
app.use(staticAssetsFromS3);
});

According to the code above, ExpressJS will try to serve the assets from local container, if ExpressJS can’t serve the assets from local cluster, ExpressJS will try to serve them from s3.

Further Improvements

To serve the files from s3, you need to upload them to s3. I’m using Octopus for continuous delivery and it already has a defined step (Upload Static Assets to S3) to upload necessary files to any bucket of yours in s3. Automation is cool — that way you won’t forget to upload files manually.

Happy coding!

P.S. — Apparently Phil Karlton really said that??? https://skeptics.stackexchange.com/a/39178

--

--

Semih Sogutlu

Cinephile. Theater lover. Senior engineer who engineers at Happy Money