Next.js: setup a CloudFront Function to redirect users to locale specific pages
If you've setup next-intl (or an other internationalization library) on your Next.js project and you don't want to deploy the
middleware on a Lambda, an EC2 or something else, you can deploy a CloudFront Function that will redirect your
users to locale specific pages (e.g. /about
-> /en/about
).
Why deploy this on a CloudFront Function ? Because it's executed at Edge Location and provides the lowest latency. CloudFront Function has a lot of limitations but it's perfect for our use case.
From a static Next.js website
If you've follow the steps on my previous article, you now have a static website deployed on CloudFront with optimized caching.
Disable Next.js locale detection
Here, when we add internationalization in the Next.js config, we make sure that localeDetection
is disabled as we
don't want URL paths without locale parameter to be cached.
So your Next.js config should like this :
const nextConfig = {
output: 'export',
i18n: {
locales: ['en', 'fr'],
defaultLocale: 'en',
localeDetection: false,
},
}
The CloudFront Function
Our CloudFront Function will redirect to the same URL, but prefixed by the user locale that we'll detect by priority:
- if the
NEXT_LOCALE
cookie exists, we use it - if the
accept-language
contains one of the supported language, we take the one that has the best quality (q
parameter) - by default, we use the default language
This is what the CloudFront Function look like in a CloudFormation template:
RedirectFunction:
Type: AWS::CloudFront::Function
Properties:
AutoPublish: true
Name: locale-redirect-function
FunctionConfig:
Runtime: cloudfront-js-1.0
Comment: This function redirect to locale specific sub-paths
FunctionCode: !Sub |
function handler(event) {
// TODO Change those variables to match your supported languages
var acceptedLanguages = ['fr', 'en'];
var defaultLanguage = 'en';
var host = '${DomainName}';
// Return the preferred given accepted-language header et NEXT_LOCALE cookie values
function getLanguage(header, cookie) {
if(cookie && acceptedLanguages.includes(cookie)) {
return cookie;
}
if(!header) {
return defaultLanguage;
}
var localesWithQuality = header.split(',')
.map(val => {
var splittedVal = val.split(';');
var locale = splittedVal[0];
var quality = splittedVal[1];
locale = locale.split('-')[0];
if(locale) {
locale = locale.toLowerCase();
}
if(quality) {
quality = parseFloat(quality.split('q=')[1]);
}
if(isNaN(quality) || (!quality && typeof quality !== 'number')) {
quality = 1;
}
return {locale, quality};
})
.filter(val => acceptedLanguages.includes(val.locale))
.sort((a, b) => b.quality - a.quality);
return localesWithQuality.length > 0 ? localesWithQuality[0].locale : defaultLanguage;
}
// Given CloudFront query string format, returns query string as string
function getQueryStringAsString(queryString) {
var res = Object.keys(queryString).reduce((acc, qsKey) => {
var qsValue = queryString[qsKey];
if(qsValue.multiValue) {
return acc + qsValue.multiValue.map(v => qsKey + '=' + v.value).join('&') + '&';
} else {
return acc + qsKey + '=' + qsValue.value + '&';
}
}, '').slice(0, -1);
return res ? '?' + res : res;
}
var request = event.request;
if(request.headers.host) {
host = request.headers.host.value;
}
if(request.method !== 'GET') {
return request;
}
var headers = request.headers;
var cookies = request.cookies;
var acceptLanguageHeader, acceptLanguageCookie;
if(headers['accept-language']) {
acceptLanguageHeader = headers['accept-language'].value;
}
if(cookies['NEXT_LOCALE']) {
acceptLanguageCookie = cookies['NEXT_LOCALE'].value;
}
var language = getLanguage(acceptLanguageHeader, acceptLanguageCookie);
var newUrl = `https://${!host}/${!language}${!request.uri}${!getQueryStringAsString(request.querystring)}`;
headers.location = { value: newUrl };
// Remove the original host header to avoid a circular reference of never ending redirects
delete headers.host;
return {
statusCode: 302,
statusDescription: 'Found',
headers,
cookies,
};
}
Note that we can't use keywords like const
or let
because CloudFront Function supports only EcmaScript 5.1 and only
some features of ES 6+ (see documentation).
CloudFront Distribution
Now, to associate your CloudFront Function to your CloudFront Distribution, you should add a FunctionAssociation
to your DefaultCacheBehavior
.
Also, we need to add two cache behaviors (one for /language et one for /language/*) per supported language so the CloudFront Function will not be triggered when the language prefix is in the URL.
We could have detected the language prefix in the CloudFront Function to avoid adding those cache behaviors but CloudFront Functions have a cost so it's more cost-effective to not trigger them when it's not necessary.
Distribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
...
DefaultCacheBehavior:
...
FunctionAssociations:
- EventType: viewer-request
FunctionARN: !GetAtt RedirectFunction.FunctionARN
...
CacheBehaviors:
- PathPattern: /fr
TargetOriginId: next-s3-origin
CachePolicyId: !Ref NextServerCachePolicy
ForwardedValues:
QueryString: true
Cookies:
Forward: all
Compress: true
AllowedMethods:
- GET
- HEAD
ViewerProtocolPolicy: redirect-to-https
- PathPattern: /fr/*
TargetOriginId: next-s3-origin
CachePolicyId: !Ref NextServerCachePolicy
ForwardedValues:
QueryString: true
Cookies:
Forward: all
Compress: true
AllowedMethods:
- GET
- HEAD
ViewerProtocolPolicy: redirect-to-https
- PathPattern: /en
TargetOriginId: next-s3-origin
CachePolicyId: !Ref NextServerCachePolicy
ForwardedValues:
QueryString: true
Cookies:
Forward: all
Compress: true
AllowedMethods:
- GET
- HEAD
ViewerProtocolPolicy: redirect-to-https
- PathPattern: /en/*
TargetOriginId: next-s3-origin
CachePolicyId: !Ref NextServerCachePolicy
ForwardedValues:
QueryString: true
Cookies:
Forward: all
Compress: true
AllowedMethods:
- GET
- HEAD
ViewerProtocolPolicy: redirect-to-https
All done!
It's all done! Now when a user will come to your root page my-domain.com/
, he will be redirected to my-domain.com/en
or
my-domain.com/fr
depending on its preferred language.