Next.js TypeScript serverless deploy with SSR and ISR with AWS CDK

🌐   written in English

~‏‏‎ ‎‏‏‎ ‎12m 12s ‎‏‏‎ ‎‏‏‎⌛

Incremental Static Regeneration is the Next.js superpower. We’ll deploy a Next.js webapp in AWS using Lambda@Edge and CloudFront with support for SSR and ISG. Both our Next.js webapp and our Infrastructure as Code (IaC) will use TypeScript, using the Cloud Development Kit (CDK).

When you have a highly dynamic webapp with a lot of content, your number of
pages will grow and grow. Static generation is great, but more pages, more time
to build. Server Side Rendering is great to solve some of these problems but
takes its cut on Time To First Byte (TTFB). Also, long builds with unnecessary
computation might also incur additional expenses. Ideally, your application is
intelligent enough to understand which products changed and incrementally update
those pages with no full rebuild. And Incremental Static Regeneration just do
that in Next.js. Until now I was just able to deploy functional webapps with ISR
in AWS using containers (ECS or Fargate), but now, thanks to new releases for
CDK Construct we can make full serverless deployment of rich webapps on AWS with
CDK.

# What you need to know

# Gotta Fetch’Em All!

Our application will fetch data from the PokéAPI — will
be not much fancy, just a regular page that fetches some data and render a page
server-side, and in a specific route, we’ll make a “starter build” with the
first 25 Pokémons, and then regenerate on the go new Pokémons as requested.

I generated all the types, Pokedex.ts and Pokemon.ts from
quicktype. Even their examples are from
PokéAPI! I used their VS Code
Paste JSON as Code extension.
It takes the API JSON output and returns interfaces and types to better deal
with our logic. The autocomplete for the nested attributes on the JSON tree of
an object makes the developer experience great, this is what TypeScript was born
for.

# Next.js

This tutorial assumes you already know how to setup your environment and take
your first steps with Next.js (install, running, create some pages). And knows
what is TypeScript, which, has an excellent support on Next.js. To generate a
brand new project just run your weapon of choice npm or yarn:

npx create-next-app --ts
# or
yarn create next-app --typescript

Or read the great
official documentation from Next.js team on TypeScript integration.

We’ll use the pattern to create an /src folder and place our pages and
components folder there. I like to create at root level a lib and types
folder and a build folder with the code to deploy our stack. After install
all dependencies, dev dependencies and deploy it to AWS, your root folder for
this project will look like this:

.
public/
lib/
src/
node_modules/
build/
types/
cdk.out/
cdk.json
bin.ts
stack.ts
next-env.d.ts
next.config.js
package-lock.json
package.json
tsconfig.json
README.md

Since we don’t need build or cdk.out to be on version control, remember to
place both on your .gitignore file.

# TSConfig

Our tsconfig.json will work both for our CDK and our Next.js. How cool is
that?

{
"compilerOptions": {
"alwaysStrict": true,
"downlevelIteration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSourceMap": true,
"lib": ["es2020", "DOM"],
"moduleResolution": "node",
"noEmitOnError": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"strict": true,
"strictBindCallApply": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
"stripInternal": true,
"target": "ES2020",
"typeRoots": ["node_modules/@types"],
"useDefineForClassFields": true,
"allowJs": true,
"skipLibCheck": true,
"noEmit": true,
"module": "commonjs",
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./lib/*"],
"@/pages/*": ["./src/pages/*"],
"@/types/*": ["./types/*"]
}
},
"exclude": ["node_modules"],
"include": ["src", "next-env.d.ts", "**/*.ts", "**/*.tsx", "lib"]
}

Some notes about it: compilerOptions.lib must have [‘es2020’, ‘DOM’] at
least. Our application will have a life in the lambda functions in Node.js and
in the browser as a JavaScript webapp. I always use absolute paths
compilerOptions.paths[‘@/components/*’] per example. It makes so clean the
imports and is more clear the domains. For this app we’ll follow the strict
path.

# CDK dependencies and configurations

All CDK dependencies are development dependencies. I will show examples in npm
from now on, but feel free to use yarn or whatever please you.

npm install -D aws-cdk @aws-cdk/core @sls-next/cdk-construct @sls-next/lambda-at-edge @aws-cdk/aws-lambda ts-node

As highlight, aside from
CDK and its construct libraries,
ts-node is a TypeScript execution engine and REPL for Node.js.
It JIT transforms TypeScript into JavaScript, enabling you to directly execute
TypeScript on Node.js without pre-compiling. This is accomplished by hooking
node’s module loading APIs, enabling it to be used seamlessly alongside other
Node.js tools and libraries.

And we’ll need some types for our webapp:

npm install -D @types/gtag.js @types/node @types/react

So, we create cdk.json:

{
"app": "npm run deploy"
}

That will instruct CDK to run a specific script we need to add to the scripts
section of the package.json along side Next.js ones:

  "scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"cdk": "cdk",
"deploy": "ts-node bin.ts"
}

So, the deploy command will run the file bin.ts:

import * as cdk from "@aws-cdk/core";
import { Builder } from "@sls-next/lambda-at-edge";
import { NextStack } from "./stack";

const builder = new Builder(".", "./build", { args: ["build"] });

builder
.build(true)
.then(() => {
const app = new cdk.App();
new NextStack(app, "NextJsPokeStack", {
env: {
region: "us-east-1",
},
analyticsReporting: true,
description: "Testing deploying NextJS Serverless Construct",
});
})
.catch(e => {
console.error(e);
process.exit(1);
});

That is importing the stack.ts file, with our infra declaration for the
NextJSLambdaEdge:

import * as cdk from "@aws-cdk/core";
import { Duration } from "@aws-cdk/core";
import { NextJSLambdaEdge } from "@sls-next/cdk-construct";
import { Runtime } from "@aws-cdk/aws-lambda";

export class NextStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
new NextJSLambdaEdge(this, "NextJsApp", {
serverlessBuildOutDir: "./build",
runtime: Runtime.NODEJS_12_X,
memory: 1024,
timeout: Duration.seconds(30),
withLogging: true,
name: {
apiLambda: `${id}Api`,
defaultLambda: `Fn${id}`,
imageLambda: `${id}Image`,
},
});
}
}

Checkout the docs for more
available props.
I recommend the use of naming each one of three lambda functions created:
apiLambda, defaultLambda and imageLambda. Because otherwise will create
with a specific name and if you happen to deploy another stack in the same
account you will get an error about the a function with that name already
exists. So, rename your stacks or even better, add some randomness to names.

We will create two different renders, [ditto].tsx for Server Side Rendering
(SSR), that is basically the same of static props, but in server side and
because of it, dynamic by nature.

import { GetServerSideProps } from "next";
import Head from "next/head";
import { getPokemonData } from "@/lib/fetch";
import PokemonForm from "@/components/pokemon";

import type { Pokemon } from "@/types/Pokemon";

interface PokemonApi {
data: Pokemon;
}

const Ditto = (props: PokemonApi) => {
if (!props?.data?.name) return null;

const pokeName =
props.data.species.name.charAt(0).toUpperCase() +
props.data.species.name.slice(1);

return (
<section className='container'>
<Head>
<title>{pokeName} | PokéServeless - AWS Serverless Lambda@Edge</title>
<meta
property='og:title'
content={`${pokeName} | PokéServeless - AWS Serverless Lambda@Edge`}
key='title'
/>
</Head>
<header>
<h1>PokéServerless — Server Side Rendering</h1>
</header>
<PokemonForm poke={props} />
</section>
);
};

export const getServerSideProps: GetServerSideProps = async context => {
let data;

const { ditto } = context.query;

if (typeof ditto === "string") {
data = await getPokemonData(ditto);
} else {
data = {};
}

return { props: { data } };
};

export default Ditto;

And [porygon].tsx, to do Incremental Static Regeneration (ISR). This is an
overview of the flow of ISR:

ISR
Incremental Static Regeneration on Next.js

Its from Lee Robinson’s
“A Complete Guide To Incremental Static Regeneration (ISR) With Next.js”
pots and its a very fair title.

This is my code:

// Example of ISG
import { GetStaticPaths, GetStaticProps } from "next";
import Head from "next/head";
import { useRouter } from "next/router";
import { getPokemons, getPokemonData } from "@/lib/fetch";
import PokemonForm from "@/components/pokemon";

import type { Pokemon } from "@/types/Pokemon";
import type { Pokedex } from "@/types/Pokedex";

interface PokemonApi {
data: Pokemon;
date: string;
}

const Porygon = (props: PokemonApi) => {
if (!props?.data?.name) return null;
const router = useRouter();

if (router.isFallback) {
return <div>Loading......I had to fetch incrementally!!</div>;
}

const pokeName =
props.data.species.name.charAt(0).toUpperCase() +
props.data.species.name.slice(1);

return (
<>
<section className='container'>
<Head>
<title>
{pokeName} | PokéServerless - AWS Serverless Lambda@Edge
</title>
<meta
property='og:title'
content={`${pokeName} | PokéSSR - AWS Serverless Lambda@Edge`}
key='title'
/>
</Head>
<header>
<h1>PokéServerless — Incremental Static Regeneration</h1>
</header>
<PokemonForm poke={props} />
</section>
<p className='poke-center'>{`Generated at ${new Date(
props.date
).toLocaleString()}
`
}</p>
</>
);
};

export const getStaticProps: GetStaticProps = async context => {
let data;

if (context.params) {
data = await getPokemonData(context.params.porygon as string);
} else {
data = {};
}

return {
props: {
data,
date: new Date().toISOString(),
},
revalidate: 60 * 5,
};
};

export const getStaticPaths: GetStaticPaths<{ porygon: string }> = async () => {
const pokemons = (await getPokemons(25)) as Pokedex;

const paths = pokemons.results.map(pokemon => {
return { params: { porygon: pokemon.name.toString() } };
});

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

export default Porygon;

To leverage all the power of components we’ll use the same component for each
strategy, which we’ll call… pokemon.ts:

import Image from 'next/image'
import Link from 'next/link'
import Button from '@/components/button'
import Spacer from '@/components/spacer'
import { Pokemon, Type } from '@/types/Pokemon'

interface PokemonInfo {
poke: {
data: Pokemon
}
}

const PokemonForm = (props: PokemonInfo) => {

const pokeImage = props.poke?.data?.sprites?.other?."official-artwork"]?.front_default ?? props.poke?.data?.sprites?.front_default;

const number = props?.poke?.data?.order;
const isPositive = number >= 1;
const pokeNumber = isPositive ? number : "Max version";

return (
<>
<article className="ditto">
<Image
src={pokeImage}
width={240}
height={240}
alt={`Pokémon ${props?.poke?.data?.name}`} />

<h1 className="poke-name">{props?.poke?.data?.name}</h1>
<p>Number: {pokeNumber}</p>
<p>Type:</p>
<ul className="poke-list">
{props?.poke?.data?.types?.map((info: Type, index: number) => (
<li key={index}> {info.type.name}</li>
))}
</ul>

<Button />
</article>

<div className="poke-footer">
<div></div>
<div className="poke-options">
<div>
<Link href="/ssr">
<a title="Server Side Rendering">
<Image
src="https://raw.githubusercontent.com/ibrahimcesar/nextjs-ssr-cdk-aws/main/public/ditto.png"
width="125"
height="112"
/>
<h2>Server Side Rendering<br/>(SSR)</h2>
</a>
</Link>
</div>

<div>
<Link href="/isr">
<a title="Incremental Static Regeneration">
<Image
src="https://raw.githubusercontent.com/ibrahimcesar/nextjs-ssr-cdk-aws/main/public/porygon.png"
width="125"
height="112"
/>
<h2>Incremental Static Regeneration<br/>(ISR)</h2>
</a>
</Link>
</div>
</div>
<div></div>
</div>
</>
)
}

export default PokemonForm

It has do some checks, if the number is equal or bigger than one (because
Dynamax and Gigamax versions doesn’t return numbers) and which image to use, and
we render with the
Image component from Next.js
for optimization.

I also made two different “home pages” for each strategy, SSR and ISR, and you
could checkout the bare-bones and ugly site for demonstration purposes only™.

Then, to deploy you just run in your root folder cdk and you should see and
approve the changes and wait for ir to finish:


cdk deploy

If you are like me and have several profiles in your AWS credentials file, as a
“advice”, I would argue to always explicit use cdk deploy --profile personal
or whatever you named to not mix environments, resources, stacks and accounts!

After done you’ll get a ✅ with the name of your stack, mine being
NextJsPokeStack and you can search the address in the CloudFront distribution,
or configure a domain in the props or output yourself this value from the
process if you needed.

And… done ✅ You successfully deployed Next.js serverless in AWS with the help
of AWS CDK. This is the final infra created, made by the
awesome tool cdk-dia:

ISR
Our final infracstructure diagram made provisioned for our. Click here for a full version

Earlier this year AWS launched the Amplify SSR support for Next.js but… is
stuck at version 9 at the time — you can’t use the Image component and the ISR
at the time. And we just made a isomorphic deployment of code AND infra with
TypeScript.