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.headers;

          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.