Building A Multilingual Static Site Using Next.js, Tailwind, TypeScript, AWS S3, CloudFront, And Athena

Building A Multilingual Static Site Using Next.js, Tailwind, TypeScript, AWS S3, CloudFront, And Athena

August 23, 2022
Landing Page
Next.js, Tailwind, S3, CloudFront, Athena

Prerequisite #

Goal #

The result is a static site with support for multiple languages. The site has repeated common information across all pages along with unique information on each page. The dynamic routing function provided by Next.js is used for implementing following path structure.

/newyork-us/en
/newyork-us/zh
/auckland-nz/en
/auckland-nz/zh

The site is designed for running a serial of events in different locations.

The site is hosted in the AWS Simple Storage Service (S3) and distributed through the AWS CloudFront, a CDN service.

Building A Static Site Using Next.js #

ReactJS helps building reusable page components. This is time saving for building multilingual landing pages.

Landing Page Starter Template #

First let’s clone the Next-JS-Landing-Page-Starter-Template from GitHub. With no particular reason I would like to keep a history of the source so I do a normal clone from the starter repo.

> git clone https://github.com/ixartz/Next-JS-Landing-Page-Starter-Template.git

Alternatively, you can remove the Git history from this starter kit by adding --depth 1 when clone.

> git clone --depth 1 https://github.com/ixartz/Next-JS-Landing-Page-Starter-Template.git

Let’s install all the dependencies and prepare for the first test run.

> npm install

Once all the packages are installed please run following command to check everything runs ok.

> npm run dev

Default page view

Then we can go to http://localhost:3000 and we should see the page below. If in a rare case that your port 3000 is used by other application then the Next.js dev server will try to find the next available port automatically. Please take a look the log output for more information.

Optional Then add a new origin which is your own Git repository where you want to keep tracking of your new site. This is optional but it’s often safer to keep a copy remotely. I use GitHub since it’s free for unlimited private repositories.

Plan dynamic routing #

Open the src/pages folder and create following strcuture and the new file.

src
|-pages
| |-[city]          <- New folder with name "[city]"
| | |-[locale]      <- New folder with name "[locale]"
| | | |-index.tsx   <- New file with name "index.tsx"

index.tsx

import { GetStaticPaths, GetStaticProps } from 'next';

const Index = () => <div>hello</div>;

export default Index;

export const getStaticPaths: GetStaticPaths = async () => {
  const paths = [
    { params: { city: 'newyork-us', locale: 'en' } },
    { params: { city: 'newyork-us', locale: 'zh' } },
    { params: { city: 'auckland-nz', locale: 'en' } },
    { params: { city: 'auckland-nz', locale: 'zh' } },
  ];

  return {
    paths,
    fallback: false,
  };
};

export const getStaticProps: GetStaticProps = async () => {
  const post = {};
  return { props: post };
};

The getStaticPaths function is a handler pre-defined by the Next.js framework so that the Static Site Generation process can create all the paths specified by this function. The path parameter values (i.e. values for ‘city’ and ‘locale’) will be used in combination with the folder structure template laid out above. It will result a folder structure like this.

newyork-us
|-en
| |-index.html
newyork-us
|-zh
| |-index.html
auckland-nz
|-en
| |-index.html
auckland-nz
|-zh
| |-index.html

For more information please check Next.js reference for getStaticPaths.

The getStaticProps function is used for getting content from a separate source before render the page component. It usually reach out to a headless CMS system for getting the content. Over here we just provide an empty object as a placeholder for now. For more information please check Next.js reference for getStaticProps.

Build And Export #

Let’s update the package.json so that it combines build and export into one command.

"scripts": {
    ...
    "export": "next build && next export",
    ...
}

Now let’s run the export command and see how it goes.

> npm run export

We should see a new folder out gets created and it should look like this.

Export result

Debug Export #

The normal debug options does not work well with static page generation. Let’s use the old school console debug() function.

index.tsx

import { debug } from 'console';

...

export const getStaticProps: GetStaticProps = async (
  context: GetStaticPropsContext
) => {
  debug(`---- Start Get Static Props <Context> ----`);
  debug(context);
  debug(`---- End Get Static Props <Context> ----`);
  const post = {};
  return { props: post };
};

NOTE: The starter kit has Visual Studio Code launch configurations setup for both browser and server side debugging. Please use the debug instructions on Next.js documentation as a reference guide if feel curious to try.

Now let’s run the export command again and see the debug output in terminal.

> npm run export

The output will show multiple outputs for the context object. Each has different values for city and locale. One example looks like this.

{
  params: { city: 'newyork-us', locale: 'en' },
  locales: undefined,
  locale: undefined,
  defaultLocale: undefined
}

Let’s use these two parameters to load content from a local JSON file.

Prepare Multilingual Content #

Let’s create a new folder .content in root of this project and 2 JSON files for both the English and Chinese content we are going to display.

<project-root>
|-.content
| |-en.json
| |-zh.json

en.json

{
  "tagLine": "Rainbow appears after storms",
  "cities": {
    "newyork-us": {
      "name": "New York, United States"
    },
    "auckland-nz": {
      "name": "Auckland, New Zealand"
    }
  }
}

zh.json

{
  "tagLine": "宇過天晴",
  "cities": {
    "newyork-us": {
      "name": "美國,紐約"
    },
    "auckland-nz": {
      "name": "新西蘭,奧克蘭"
    }
  }
}

Then we need to add a class for loading the content in a type-safe way. Let’s add a folder under the src folder and name it as localization. Then please add a TypeScript file PageContent.ts

<project-root>
|-src
| |-localization
| | |-PageContent.ts

PageContent.ts

import en from '../../.content/en.json';
import zh from '../../.content/zh.json';

export type City = {
  name: string;
};

export type LocalizedContent = {
  tagLine: string;
  cities: { [name: string]: City };
};

const allContent: { [name: string]: LocalizedContent } = {
  en: <LocalizedContent>en,
  zh: <LocalizedContent>zh,
};

export type PageContent = {
  tagLine: string;
  city: City;
};

export type GetPageContent = (
  city: string,
  locale: string
) => PageContent | undefined;

const getPageContent: GetPageContent = (cityCode: string, locale: string) => {
  const localizedContent = allContent[locale];
  if (localizedContent == null) {
    return undefined;
  }
  const city = localizedContent.cities[cityCode];
  if (city == null) {
    return undefined;
  }
  const pageContent: PageContent = {
    tagLine: localizedContent.tagLine,
    city,
  };
  return pageContent;
};

export default getPageContent;

The first two lines load the JSON content as a generic object which are then casted into the LocalizedContent type. If the structure of the JSON differs from the casting type Visual Studio Code will provide a warning.

Then we have two types defined. The PageContent is a type for an object that carries data for rendering the page. This object will be passed into the React components that constructs the page. The GetPageContent is a type for a function that’s responsible for returning the right content. The returning type of this function is either a PageConent object or undefined.

Then we have the first version of the getPageContent function defined. This function takes two parameters (i.e. cityCode and locale). These will be values get from the context object in the getStaticProps function in the index.tsx.

Rendering Content #

Let’s update the index.tsx for displaying the content we defined in the JSON file. We can assume the right content will be provided by a high level function and inside of the index.tsx we just need to figure out the layout.

<project-root>
|-src
| |-pages
| | |-[city]
| | | |-[locale]
| | | | |-index.tsx

index.tsx

type Props = {
  pageContent: PageContent;
};

const Index: NextPage<Props> = (props) => {
  const { pageContent } = props;
  return (
    <>
      <div>{pageContent.tagLine}</div>
      <div>{pageContent.city.name}</div>
    </>
  );
};

export default Index;

First we defined a property type that this React component uses. This enables the component to receive content in a type-safe way and the Visual Studio Code can help coding with intellisence.

Then we use object destructing to get the content object from the props and use it for rendering HTML.

👍 CREDIT: How to set types for functional component props in Nextjs with TypeScript? (By Melvin George, April 28, 2021) For me to find a solution for typing the Index function.

Get Localized Content #

Then we also need to update the getStaticProps function in the index.tsx.

index.tsx

interface Params extends ParsedUrlQuery {
  city: string;
  locale: string;
}

export const getStaticProps: GetStaticProps<Props, Params> = async (
  context
) => {
  const params = context.params!;
  const { city, locale } = params;
  const pageContent = getPageContent(city, locale)!;
  debug(`\n---- Start Get Static Props <pageContent> ----`);
  debug(pageContent);
  debug(`---- End Get Static Props <pageContent> ----`);
  return { props: { pageContent } };
};

A non-null assertion operator is used to ensure the params is a defined object. Then we get the city’s code and locale using a destructing statement. Then through the pageContent object is fetched using the function we defined earlier. Finally the content object is returned through an object that has a props property that is conformed with the Props type that we defined for the Index component.

👍 CREDIT: GitHub Comment (By Gianluca Gippetto, November 15, 2020) For me to find a solution for typing the getStaticProps function.

DEV Run And Test #

Next.js has a dev server that you can get live change reloaded without refresh the browser manually. Let’s run the following command.

> npm run dev

Open the http://localhost:3000/newyork-us/en should display this.

New York, US (English)

Go to http://localhost:3000/newyork-us/zh should display this.

New York, US (Chinese)

Improve Code Quality #

The getPageContent function looks ok but returning undefined is never a good idea in programming. This is like an air bubble in the blood flowing in our veins. So I’m going to default content to English. Meantime it’s also fine to throw an exception to let the higher level logic to handle situation that lower level has no idea how to deal. The balancing of who handles what requires many try and fail.

The second version of the getPageContent function looks like this.

PageContent.ts

export type GetPageContent = (city: string, locale: string) => PageContent;

const getPageContent: GetPageContent = (cityCode: string, locale: string) => {
  let localizedContent = allContent[locale];
  if (localizedContent == null) {
    // Protected by compile time type checking that 'en' has value
    localizedContent = allContent.en!;
  }
  let city = localizedContent.cities[cityCode];
  if (city == null) {
    const cityCodes = Object.keys(localizedContent.cities);
    // Compile time type checking does not guarantee existence of a value.
    const defaultCityCode = cityCodes[0]!;
    city = localizedContent.cities[defaultCityCode]!;
  }
  const pageContent: PageContent = {
    tagLine: localizedContent.tagLine,
    city,
  };
  return pageContent;
};

The non-null assertion operator (i.e. !) tells the type checking to ignore the possibility of a nullness situation. It does not raise error when the value is null or undefined. In fact it seems swallows the index out of range error from this line below … not good! :(

const defaultCityCode = cityCodes[0]!;

At this point we need to decide if need to add more logic to make sure we will get a default city code or throw an error. My choice is to let the error thrown so the testers can easily tell something seriously wrong with the site’s configuration setup.

const getPageContent: GetPageContent = (cityCode: string, locale: string) => {
  let localizedContent = allContent[locale];
  if (localizedContent == null) {
    // Protected by compile time type checking that 'en' has value
    localizedContent = allContent.en!;
  }
  const city = localizedContent.cities[cityCode];
  if (city == null) {
    throw new Error(
      `City code '${cityCode}' does not have corresponding data found in localized content for '${locale}'`
    );
  }
  const pageContent: PageContent = {
    tagLine: localizedContent.tagLine,
    city,
  };
  return pageContent;
};

Style Using Tailwind #

Let’s improve the styling of the page by making the content more responsive. Tailwind is a big topic and will prepare separate post to introduce it. Over here just want to extend the content model and demonstrate using Tailwind CSS classes without writing a single line of CSS.

First let’s add some new content into en.json and zh.json. I used Lorem ipsum text generator.

en.json

{
  ...

  "features": {
    "us": "Lorem ipsum dolor ...",
    "nz": "Fusce est libero, ..."
  }
}

zh.json

{
  ...

  "features": {
    "us": "Lorem Ipsum,也稱亂數假文或者啞元文本...",
    "nz": "它不僅延續了五個世紀,還通過了電子排版的挑戰..."
  }
}

Then let’s update the LocalizedContent class and introducing a new class Features.

PageContent.ts

export type Features = {
  us: string;
  nz: string;
};

export type LocalizedContent = {
  tagLine: string;
  cities: { [name: string]: City };
  features: Features;
};

Then we need to update the PageConent class and the getPageContent function.

index.tsx

export type PageContent = {
  tagLine: string;
  city: City;
  features: Features;
};

...

const getPageContent: GetPageContent = (cityCode: string, locale: string) => {
  const localizedContent = allContent[locale];
  if (localizedContent == null) {
    return undefined;
  }
  const city = localizedContent.cities[cityCode];
  if (city == null) {
    return undefined;
  }
  const pageContent: PageContent = {
    tagLine: localizedContent.tagLine,
    city,
    features: localizedContent.features,
  };
  return pageContent;
};

Now let’s add these content into the component.

index.tsx

  return (
    <div className="max-w-screen-lg mx-auto">
      <div className="flex flex-col text-center">
        <div className="mt-4 text-xl">{pageContent.tagLine}</div>
        <div className="mt-2">{pageContent.city.name}</div>
      </div>
      <div className="flex flex-wrap">
        <div className="w-full mt-4 md:w-1/2 px-6">
          {pageContent.features.us}
        </div>
        <div className="w-full mt-4 md:w-1/2 px-6">
          {pageContent.features.nz}
        </div>
      </div>
    </div>
  );

Hope the dev server is still running and then you should see the following outcome in desktop view and mobile view.

Desktop View

Desktop View

Mobile View

Mobile View

Hosting On S3 And CloudFront #

AWS S3 can be used for hosting static website by itself. By using a CDN it can boost the page loading performance significantly. We also want to do some rate limiting so that each IP address can only do certain amount of requests within a timeframe. We will use a CloudFront function to set the default document to URI when it’s not provided. Last but not least we want to keep our CloudFront access log in a separate S3 bucket and use plain SQL query to analyze the traffic through AWS Athena.

AWS Achitecture

We are going to setup 2 environment. One production and one staging for internal review. The staging site is protected by a basic authentication that implemented through a Lambda@Edge function.

Use Route53 As Primary DNS #

Suppose we bought a domain through Godaddy where it’s more affordable. We can update the domain’s “Nameservers” setting for migrating the free DNS service Godaddy provided to AWS Route 53 so that we can use custom domain for the CloudFront distribution.

First we need to follow the instructions in Creating a public hosted zone to create a hosted zone with the same name as the doamin you are using for hosting this website. Once it’s created a list of nameservers can be found in the hosted zone. They are the NS record for this hosted zone and sometimes appears similar to this ns-998.awsdns-60.net. It starts with ‘ns’ and then has ‘awsdns’ as part of the value. Copy all of them (usually 4) into the GoDaddy’s Nameservers setting.

Create Hosted Zone

Use the domain you want to be hosted by the Route53.

Use xiuyi.fun as the hosted zone

Add the name server (NS) records to replace the default nameservers in Godaddy.

NS records

Export Zone File #

You can export Godaddy’s DNS settings through a text file (as known as the Zone File). By importing these settings into Route53 you are able to get uninterrupted DNS service when name servers are switched.

Export Zone File

Zone File Content

The exported Zone File has some records that are not usable for other DNS providers other than Godaddy.

Import Zone File #

In the hosted zone go to the Records section and click the Import zone file. Copy paste the exported zone file into the text area for Zone file.

Then remove the NS records, SOA records, and the A record from the imported zone file content.

Remove NS records from Imported Zone File

Remove the SOA records

Remove the A record

Scroll to the bottom and click Import.

Successful import

After successfully importing the Zone File we are ready to switch the name server

Switch Name Server #

⚠️ The following instructions are ONLY applicable to a brand new domain. Nameserver migration is a tedious process especially when DNSSEC is enabled. DNSViz.net and DNSSEC Analyzer are two great tools to help. AWS provides detailed instructions for Making Route 53 the DNS service for a domain that’s in use.

Go to DNS management for the domain in Godaddy. In the Nameservers section click Enter your own nameservers.

Enter your own nameservers

Enter nameservers

Go to the Route53 hosted zone and find out the NS records.

NS records

Enter NS records

Upon saving the changes a warning message is provided. Please confirm the import.

Confirm import

Confirm update

DNS update will take some time to be effective.

Before Before DNS Migration

After After DNS Migration

Create A Sub-Domain for Staging #

We are going to deploy the site to a password protected staging site first. With primary DNS managed by Route53 it’s easy to create a subdomain.

Open Route53 and go to Hosted zones.

Click Create hosted zone.

Hosted zone

Put your staging domain name for example staging.<your domain name>.

Staging domain name

Click Create hosted zone.

Copy all 4 DNS records.

Go back to the Hosted zones. Open the root domain. Click Create record.

Copy NS records

Put the subdomain name (e.g. staging) into the Record name field.

Choose Record type NS - Name servers for a hosted zone.

Paste the NS values from the previous screen into the Value field.

NS records for subdomain

Click the Create records.

Now the subdomain is created successfully and connected with the root domain.

Request SSL Certificate #

One benefit of using Route53 is getting the free SSL certificate for the hosted zone. Let’s request one for the staging site. It’s the same process when you request one for your production site. Just need to be aware that for the production site you may want to include both with and without www.. So that the certificate will work for both xiuyi.fun and www.xiuyi.fun

Go to AWS Certificate Manager.

Click the Request a certificate.

Request a certificate

Choose Request a public certificate. Then click Next.

Public certificate

In the Fully qualified domain name put staging.<your domain name>.

Then click Request.

Fully qualified domain name

The request will show status Pending validation. Click on the request’s Certification ID to open this request again.

Pending request

Click Create records in Route 53.

Create records in Route 53

In this screen all the pending request will be listed and selected. Click Create records.

Filter requests

With the successful confirmation

DNS updated successfully

We can go back to Route53 and confirm the new CNAME record is added.

New DNS record

Go back to the Certificate Manager the request may still be in the status of Pending validation.

Pending validation

Wait for 5 minutes or so and click the refresh button and the request should show status Issued.

Status issued

Create S3 Bucket #

We are going to name our S3 Bucket as xiuyi-landing-page (💡 Please choose your own name since each S3 bucket name is globally unique). S3 bucket name is globally unique though the bucket itself is a regional resource. We will deploy site content to the bucket in different “folder” for staging and production.

With this Organizing objects in the Amazon S3 console using folders you will be able to tell what is a “folder” for S3.

📚 AWS has this concept of Region, AZ, and Edge locations etc. Here is a brief introduction about Regions and Zones. If you are commited to use AWS for long-term please consider to take the AWS Certified Cloud Practitioner certificate. The free AWS Skill Builder | Cloud Essential Learning Plan (14h 16m) is a good starting point. I also recommend A Cloud Guru as a paid learning resource for preparing the certification exam. I took it and passed 3 certification exams so far.

Open the AWS Console and go to S3. Click the Create bucket button and put the name into Bucket name. For the rest just leave them as the default setting. It’s correct to disable all the public access to this bucket. We will use the Origin Access Control (OAC) to provide access for CloudFront to use this S3 bucket as the origin.

Create S3 Bucket

Let’s add a folder called out_test. Then upload the index.html to this folder.

xiuyi-landing-page
|- out_test/
|  |- index.html

index.html

<html>
    <body>
        Hello World.
    </body>
</html>

You can drag the file or a folder directly to the S3 console when it’s currently inside a bucket. It will let you upload the selected file and all sub-folders and files in the selected folder. Click Upload

Create CloudFront Distribution #

Go to CloudFront in the AWS Console and click Create distribution. Click the Origin domain and search for the S3 bucket name xiuyi-landing-page.

Create distribution

Put /out_test in the Origin path.

Use prefix for distribution

In the Origin access section choose the Origin access control settings (recommended) option. Then click the Create control setting button for creating the origin access control. Name it with the S3 bucket domain. Keep the default Sign request option selected.

Create access control

We need to manually update the S3 bucket policy. When we save the distribution the coresponding policy will be provided.

Create access control with notice for updating S3

CloudFront distribution has three pricing classes. Choose Use only North America and Europe option for lowest cost.

Price class

We will come back and setup the custom domain and associated custom SSL certificate later.

Please put index.html in the Default root object field.

Default object

Upon save the new distribution a notification is provided. Click the Copy policy button to keep a copy of the policy. Let’s open the S3 bucket and choose Permissions tab.

S3 Policy update

S3 Policy update

Scroll down to the Bucket policy section and click the Edit button. Paste the policy into the text field. In case there’re other policies you will need to manually merge them.

S3 Policy update

S3 Policy update

Create Lambda for Password Protection And URL Rewrite #

Open the AWS Lambda console and click Create function. Select the first option Author from scratch.

Name the function passwordProtection. Use the Node.js 16.x as the Runtime.

Create Lambda

Since we are going to deploy to the Edge let’s use x86_64 for the Architecture. In other cases using “arm64” can be more cost effective. For more information please see Lambda instruction set architectures.

Please add following code to the index.js.

'use strict';

exports.handler = (event, context, callback) => {

    // Get request and request headers
    const request = event.Records[0].cf.request;
    const headers = request.headers;

    // Configure authentication
    const authUser = 'username';
    const authPass = 'password';

    // Construct the Basic Auth string
    const authString = 'Basic ' + new Buffer(authUser + ':' + authPass).toString('base64');

    // Require Basic authentication
    if (typeof headers.authorization == 'undefined' || headers.authorization[0].value != authString) {
        const body = 'Unauthorized';
        const response = {
            status: '401',
            statusDescription: 'Unauthorized',
            body: body,
            headers: {
                'www-authenticate': [{key: 'WWW-Authenticate', value:'Basic'}]
            },
        };
        callback(null, response);
    }

    var uri = request.uri;

    // Check whether the URI is missing a file name.
    if (uri.endsWith('/')) {
        request.uri += 'index.html';
    }
    // Check whether the URI is missing a file extension.
    else if (!uri.includes('.')) {
        request.uri += '/index.html';
    }

    // Continue request processing if authentication passed
    callback(null, request);
};

The above code does two things. First it check if the request includes a basic authentication header made by the combination of “username” and “password” in the form of “username:password”. When this is not satisfied returns a response with status code 401. If the authentication requirement is satisfied the next thing this code does is to write the URL. When the requested URI does not include a file name apped the file name “index.html” to the end.

Execution Role Permission #

Let’s update the execution role permission so that this role can be assumed by two service principals: lambda.amazonaws.com and edgelambda.amazonaws.com. More information can be found in Function execution role for service principals.

Go to the Configuration tab and then choose Permissions. Click on the role name. This will open the IAM configuration for the execution role.

Update Execution Role

Go to the Trust relationships tab in the IAM configuration page.

Update Execution Role IAM

Click on Edit trust policy.

Replace the entire “Service” block with following so that multiple service principals are included.

Before

"Service": "lambda.amazonaws.com"

After

"Service": [
    "lambda.amazonaws.com",
    "edgelambda.amazonaws.com"
]

Update Trust Relationships

Click on Update policy.

Deploy Lambda To Edge #

Click Deploy to save the Lambda so that we can deploy it to edge.

Deploy to Save

Scroll back to the top and click Actions.

Then choose Deploy to Lambda@Edge.

Select the Configure new CloudFront trigger.

In the Distribution field select the distribution created for the staging site.

Create new CloudFront trigger

Choose '*' (without the quote) in the Cache behavior. This makes the Lambda being executed for all request going through this distribution. In fact the number of choices here is depend on the number of cache behaviors set for this distribution. For more information about customizing distribution please take a look [].

Let’s choose Viewer request in the CloudFront event setting. For more details about the 4 event types please refer to CloudFront events that can trigger a Lambda@Edge function

Create new CloudFront trigger

Click Deploy.

Now your Lambda is being replicated across all regions. The CloudFront trigger is associated with the specific version only. So next time when come back to this Lambda please go to the Versions tab to find the deployed Lambda and associated trigger.

Lambda Versions

Go to the distribution and copy the Distribution domain name to browser.

Login

CloudFront Alternative Domain #

With the success of testing we can add custom domain to this CloudFront distribution.

Go to the settings section of the distribution. Click Edit.

CloudFront Settings

Put staging.<your domain name> into the Alternative domain name (CNAME) field.

You can multiple domains here as long as the SSL certificate have it covered.

In the Custom SSL certificate field choose the one issued by the Certificate Manager.

Alternative domain name

Click Save changes.

Custom SSL certification is added

Using Alias For CloudFront Alternative Domain #

Go to Route53 and open the hosted zone for the alternative domain. Click Create record.

Create record

Leave the Record name empty. Enable Alias in the Route traffic to search and choose Alias to CloudFront distribution.

Enable alias

Then choose the distribution that has this alternative domain.

Choose distribution

Click Create records. A new A Type record is added.

Create records

URL Rewrite With CloudFront Functions for Production #

CloudFront Functions is different from the Lambda@Edge we just used. For a detailed comparison please refer to this Introducing CloudFront Functions – Run Your Code at the Edge with Low Latency at Any Scale

This can be used for your production CloudFront distribution where no password protection is needed.

Go to CloudFront and from the side menu on the left choose Functions.

CloudFront functions

Let’s put add-index-html in the Name field. Then click Create function.

Name the CloudFront function

Replace the existing code with the following code into Development tab under the Function code section.

function handler(event) {
    var request = event.request;
    var uri = request.uri;

    // Check whether the URI is missing a file name.
    if (uri.endsWith('/')) {
        request.uri += 'index.html';
    }
    // Check whether the URI is missing a file extension.
    else if (!uri.includes('.')) {
        request.uri += '/index.html';
    }

    return request;
}

Click Save changes.

Go to the Publish tab and click Publish function. Now you can associate this function with a distribution.

Publish CloudFront function

You can also add the association from the distribution by copy the ARN from the Details section of this function.

For small amount of distributions adding associate from the function may just be easier.

Click Add association. Choose the distribution you want to associate. Choose Viewer Request as the Event type.

Associate function to distribution

Click Add association.

Analyze CloudFront Access Log Using Athena #

When CloudFront logging is enabled we will be able to see how content is accessed and the reponse time for each item.

Enable CloudFront Logging #

Create a new S3 bucket with name of xiuyi-landing-page-cf-log (💡 Please choose your own name since each S3 bucket name is globally unique). This time we need to use the ACLs enabled option so that Athena can query the logs. We can then leave the rest with the default values.

S3 bucket for CloudFront logs

Open the distribution and in the first tab (i.e. General) Go to the Settings section and click Edit.

Turn on the Standard logging. Then choose the new bucket that is created for CloudFront logging.

Enable CloudFront logging

Then Save changes.

SQL Query CloudFront Logs Via Athena #

Every 20 minutes CloudFront saves the access log to a tab delimited gzip file. For more details about the 33 fields please refer to the Standard log file fields.

If it’s the first time using Athena we need to setup an S3 bucket for Athena output please see Specifying a query result location using the Athena console.

Let’s go to Athena and open the Query editor.

Past the following query into the Query editor for creating the table.

CREATE EXTERNAL TABLE IF NOT EXISTS default.xiuyi_landing_page_cf_log (
  `date` DATE,
  time STRING,
  location STRING,
  bytes BIGINT,
  request_ip STRING,
  method STRING,
  host STRING,
  uri STRING,
  status INT,
  referrer STRING,
  user_agent STRING,
  query_string STRING,
  cookie STRING,
  result_type STRING,
  request_id STRING,
  host_header STRING,
  request_protocol STRING,
  request_bytes BIGINT,
  time_taken FLOAT,
  xforwarded_for STRING,
  ssl_protocol STRING,
  ssl_cipher STRING,
  response_result_type STRING,
  http_version STRING,
  fle_status STRING,
  fle_encrypted_fields INT,
  c_port INT,
  time_to_first_byte FLOAT,
  x_edge_detailed_result_type STRING,
  sc_content_type STRING,
  sc_content_len BIGINT,
  sc_range_start BIGINT,
  sc_range_end BIGINT
)
ROW FORMAT DELIMITED
FIELDS TERMINATED BY '\t'
LOCATION 's3://xiuyi-landing-page-cf-log/'
TBLPROPERTIES ( 'skip.header.line.count'='2' )

After running the query a default database and table are created.

Run query

ℹ️ The above query is copied from this documentation: Querying Amazon CloudFront logs.

Use the following query can tell you the total bytes served daily.

SELECT "date", SUM(bytes) AS total_bytes
FROM xiuyi_landing_page_cf_log
GROUP BY "date"
ORDER BY "date" desc
LIMIT 100;

Tuning Cache-Control HTTP Header #

Though CloudFront is a form of cache the TTL settings in CloudFront has no impact on how long content is cached by the browser. This is achieved through the content origin. S3 object Metadata can be used for tuning how content should be cached in browser.

Cache tuning is another complex large topic we are not able to cover in this post. I highly recommend taking a look Cache-Control for Civilians.

Resources #

Please feel free to email your feedback to: editor[at]yuguotianqing.us