├── .eslintignore ├── .eslintrc ├── .gitignore ├── LICENSE.txt ├── README.md ├── next.config.js ├── package.json ├── public ├── favicon.ico └── images │ └── formidable-logo.png ├── scripts └── compare-nft.js ├── server ├── blog.js └── root.js ├── serverless.yml ├── src ├── .eslintrc ├── components │ ├── date.js │ ├── layout.js │ └── layout.module.css ├── lib │ └── posts.js ├── pages │ ├── _app.js │ ├── api │ │ └── hello.js │ ├── index.js │ └── posts │ │ └── [id].js ├── posts │ ├── pre-rendering.md │ └── ssg-ssr.md └── styles │ ├── global.css │ └── utils.module.css └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - formidable/configurations/es6-node 4 | 5 | parser: "@babel/eslint-parser" 6 | parserOptions: 7 | requireConfigFile: false 8 | 9 | rules: 10 | object-property-newline: ["error", { allowAllPropertiesOnSameLine: true }] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git 2 | 3 | .DS_Store 4 | .project 5 | .vscode 6 | node_modules 7 | npm-debug.log* 8 | yarn-error.log* 9 | /package-lock.json 10 | 11 | # test 12 | .nyc_output 13 | coverage 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | /build 19 | 20 | # infrastructure 21 | .terraform 22 | .serverless 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Formidable Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Next.js Serverless Demo 2 | ======================= 3 | 4 | [![Maintenance Status][maintenance-image]](#maintenance-status) 5 | 6 | Deploy Next.js to AWS Lambda using the Serverless Application Framework. 7 | 8 | ## Project notes 9 | 10 | This demo uses the following tools: 11 | 12 | - [Nodejs](https://nodejs.org/en/download/) 12.16+ or higher 13 | - [Yarn](https://classic.yarnpkg.com/en/docs/install) 14 | 15 | and is based on the following projects: 16 | 17 | - [nextjs-fargate-demo](https://github.com/FormidableLabs/nextjs-fargate-demo): We deploy the same Next.js application. 18 | - [aws-lambda-serverless-reference][]: A reference Serverless Application Framework project with additional Terraform support for IAM permission boundaries. 19 | 20 | ## Goals 21 | 22 | The main goals of this demo project are as follows: 23 | 24 | 1. **Slim down a Next.js Lambda deployment**: The Next.js `target: "serverless"` Node.js outputs are huge. Like really, really big because **each page** contains **all the dependencies**. This project aims to use `target: "server"` Node.js outputs to achieve a smaller package. 25 | 26 | Here's our starting point with `serverless` target: 27 | 28 | ```sh 29 | $ yarn clean && yarn build && yarn lambda:sls package --report 30 | $ du -sh .serverless/blog.zip && zipinfo -1 .serverless/blog.zip | wc -l 31 | 4.0M .serverless/blog.zip 32 | 290 33 | $ du -sh .next/serverless/pages/index.js 34 | 2.7M .next/serverless/pages/index.js 35 | ``` 36 | 37 | Here's with `server` target: 38 | 39 | ```sh 40 | $ yarn clean && yarn build && yarn lambda:sls package --report 41 | $ du -sh .serverless/blog.zip && zipinfo -1 .serverless/blog.zip | wc -l 42 | 3.0M .serverless/blog.zip 43 | 1291 44 | $ du -sh .next/server/pages/index.js 45 | 12K .next/server/pages/index.js 46 | ``` 47 | 48 | While the package sizes at 2 pages are comparable for the overall zip, the `server` (12K) vs `serverless` (2.7M) per page cost of `pages/index.js`, and each additional page, becomes apparent. 49 | 50 | 51 | 2. **Single Lambda/APIGW proxy**: The Next.js `target: "serverless"` requires you to either manually create a routing solution based on Next.js generated metadata files or use something like [next-routes](https://github.com/fridays/next-routes). However, `target: "server"` contains a router itself for one endpoint. Thus, by using the `server` target we can avoid one of the biggest pains of deploying to a single Lambda target for an entire Next.js application. 52 | 53 | ## Implementation 54 | 55 | ### Runtime 56 | 57 | We use the production-only Node server found in `next/dist/server/next-server.js` instead of the development augmented core server found in `next/dist/server/next.js`. This has a few extra constraints, but ends up being a good choice for the following reasons: 58 | 59 | - Both `next-server.js` and `next.js` get to use the built-in Next.js router that is unavailable when using `serverless` target. 60 | - The traced file bundle for `next-server.js` is much slimmer as tracing can easily skip build dependencies like `webpack`, `babel`, etc. that come in with `next.js` 61 | - Next.js itself now follows this exact model for their [experimental tracing support](https://nextjs.org/docs/advanced-features/output-file-tracing) and we can see a similar server configuration [here](https://unpkg.com/browse/next@12.1.4/dist/build/utils.js). 62 | 63 | ### Packaging 64 | 65 | We package only the individual files needed at runtime in our Lambda using the Serverless Application Framework with the [serverless-jetpack](https://github.com/FormidableLabs/serverless-jetpack) plugin. The Jetpack plugin examines all the application entry points and then traces all imports and then creates a zip bundle of only the files that will be needed at runtime. 66 | 67 | For those doing their own Lambda deployments (say with Terraform), we provide a standalone CLI, [trace-pkg](https://github.com/FormidableLabs/trace-pkg), to produce traced zip bundles from entry points. 68 | 69 | Part of the underlying bundle size problem is that the `next` package ships with a ton of build-time and development-only dependencies that artificially inflate the size of a bundle suitable for application deployment. By using the `next-server.js` runtime and file tracing, we get the smallest possible package for cloud deployment that is still correct. 70 | 71 | To read more about file tracing and integration with your applications, see 72 | 73 | - [Jetpack: trace your way to faster and smaller Serverless packages](https://formidable.com/blog/2020/jetpack-trace-your-way-to-faster-and-smaller-serverless-packages/) 74 | - [trace-pkg: Package Node.js apps for AWS Lambda and beyond](https://formidable.com/blog/2020/trace-pkg-package-node-js-apps-for-aws-lambda-and-beyond/) 75 | 76 | ## Caveats 77 | 78 | Some caveats: 79 | 80 | 1. **Static files**: To make this demo a whole lot easier to develop/deploy, we handle serve static assets _from_ the Lambda. This is not what you should do for a real application. Typically, you'll want to stick those assets in an S3 bucket behind a CDN or something. Look for the `TODO(STATIC)` comments variously throughout this repository to see all the shortcuts you should unwind to then reconfigure for static assets "the right way". 81 | 2. **Deployment URL base path**: We have the Next.js blog up at sub-path `/blog`. A consumer app may go instead for root and that would simplify some of the code we have in this repo to make all the dev + prod experience work the same. 82 | 3. **Lambda SSR + CDN**: Our React SSR hasn't been tuned at all yet for caching in the CDN like a real world app would want to do. 83 | 84 | ## Local development 85 | 86 | Start with: 87 | 88 | ```sh 89 | $ yarn install 90 | ``` 91 | 92 | Then we provide a lot of different ways to develop the server. 93 | 94 | | Command | URL | 95 | | ----------------- | ---------------------------------------------- | 96 | | `dev` | http://127.0.0.1:3000/blog/ | 97 | | | http://127.0.0.1:3000/blog/posts/ssg-ssr | 98 | | `start` | http://127.0.0.1:4000/blog/ | 99 | | | http://127.0.0.1:4000/blog/posts/ssg-ssr | 100 | | `lambda:localdev` | http://127.0.0.1:5000/blog/ | 101 | | | http://127.0.0.1:5000/blog/posts/ssg-ssr | 102 | | _deployed_ | https://nextjs-sls-sandbox.formidable.dev/blog/ | 103 | | | https://nextjs-sls-sandbox.formidable.dev/blog/posts/ssg-ssr | 104 | 105 | ### Next.js Development server (3000) 106 | 107 | The built-in Next.js dev server, compilation and all. 108 | 109 | ```sh 110 | $ yarn dev 111 | ``` 112 | 113 | and visit: http://127.0.0.1:3000/blog/ 114 | 115 | ### Node.js production server (4000) 116 | 117 | We have a Node.js custom `express` server that uses _almost_ all of the Lambda code, which is sometimes an easier development experience that `serverless-offline`. This also could theoretically serve as a real production server on a bare metal or containerized compute instance outside of Lambda. 118 | 119 | ```sh 120 | $ yarn clean && yarn build 121 | $ yarn start 122 | ``` 123 | 124 | and visit: http://127.0.0.1:4000/blog/ 125 | 126 | ### Lambda development server (5000) 127 | 128 | This uses `serverless-offline` to simulate the application running on Lambda. 129 | 130 | ```sh 131 | $ yarn clean && yarn build 132 | $ yarn lambda:localdev 133 | ``` 134 | 135 | and visit: http://127.0.0.1:5000/blog/ 136 | 137 | ## Deployment 138 | 139 | We target AWS via a simple command line deploy using the `serverless` CLI. For a real world application, you'd want to have this deployment come from your CI/CD pipeline with things like per-PR deployments, etc. However, this demo is just here to validate Next.js running on Lambda, so get yer laptop running and fire away! 140 | 141 | ### Names, groups, etc. 142 | 143 | **Environment**: 144 | 145 | Defaults: 146 | 147 | - `SERVICE_NAME=nextjs-serverless`: Name of our service. 148 | - `AWS_REGION=us-east-1`: Region 149 | - `STAGE=localdev`: Default for local development on your machine. 150 | 151 | For deployment, switch the following variables: 152 | 153 | - `STAGE=sandbox`: Our cloud sandbox. We will assume you're deploying here for the rest of this guide. 154 | 155 | ### Prepare tools 156 | 157 | *Get AWS vault* 158 | 159 | This allows us to never have decrypted credentials on disk. 160 | 161 | ```sh 162 | $ brew install aws-vault 163 | ``` 164 | 165 | We will assume you have an `AWS_USER` configured that has privileges to do the rest of the cloud provisioning needed for the Serverless application deployment. 166 | 167 | ### Deploy to Lambda 168 | 169 | We will use `serverless` to deploy to AWS Lambda. 170 | 171 | **Deploy** the Lambda app. 172 | 173 | ```sh 174 | # Build for production. 175 | $ yarn clean && yarn build 176 | 177 | # Deploy 178 | $ STAGE=sandbox aws-vault exec AWS_USER -- \ 179 | yarn lambda:deploy 180 | 181 | # Check on app and endpoints. 182 | $ STAGE=sandbox aws-vault exec AWS_USER -- \ 183 | yarn lambda:info 184 | ``` 185 | 186 | See the [aws-lambda-serverless-reference][] docs for additional Serverless/Lambda (`yarn lambda:*`) tasks you can run. 187 | 188 | As a useful helper we've separately hooked up a custom domain for `STAGE=sandbox` at: 189 | 190 | https://nextjs-sls-sandbox.formidable.dev/blog/ 191 | 192 | > ℹ️ **Note**: We set `BASE_PATH` to `/blog` and _not_ `/${STAGE}/blog` like API Gateway does for internal endpoints for our references to other static assets. It's kind of a moot point because frontend assets shouldn't be served via Lambda/APIGW like we do for this demo, but just worth noting that the internal endpoints will have incorrect asset paths. 193 | 194 | [aws-lambda-serverless-reference]: https://github.com/FormidableLabs/aws-lambda-serverless-reference 195 | [aws-vault]: https://github.com/99designs/aws-vault 196 | 197 | ## Maintenance Status 198 | 199 | **Active:** Formidable is actively working on this project, and we expect to continue for work for the foreseeable future. Bug reports, feature requests and pull requests are welcome. 200 | 201 | [maintenance-image]: https://img.shields.io/badge/maintenance-active-green.svg?color=brightgreen&style=flat 202 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // **NOTE**: We set a base path that assumes Lambda staging _and_ our 4 | // APIGW proxy base path (of `blog` by default). Many real world apps will 5 | // just have a root base path and it's probably easier than this. 6 | const { BASE_PATH } = process.env; 7 | if (!BASE_PATH) { 8 | throw new Error("BASE_PATH is required"); 9 | } 10 | 11 | module.exports = { 12 | basePath: BASE_PATH, 13 | assetPrefix: BASE_PATH, 14 | env: { 15 | BASE_PATH 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-serverless-demo", 3 | "version": "0.0.1", 4 | "description": "Simple Next.js on Lambda via Serverless demo", 5 | "main": "index.js", 6 | "repository": "https://github.com/FormidableLabs/nextjs-serverless-demo", 7 | "author": "Ryan Roemer ", 8 | "license": "MIT", 9 | "private": true, 10 | "scripts": { 11 | "lint": "eslint .", 12 | "dev": "eval $(yarn -s env) && next dev", 13 | "clean": "rm -rf .next", 14 | "build": "eval $(yarn -s env) && next build", 15 | "start": "eval $(yarn -s env) && node server/blog.js", 16 | "env": "echo export STAGE=${STAGE:-localdev}; echo export BASE_PATH=${BASE_PATH:-/blog}; echo export SERVICE_NAME=nextjs-serverless; echo export AWS_REGION=${AWS_REGION:-us-east-1}; echo export AWS_XRAY_CONTEXT_MISSING=LOG_ERROR", 17 | "lambda:sls": "eval $(yarn -s env) && sls -s ${STAGE}", 18 | "lambda:localdev": "yarn run lambda:sls offline start --httpPort ${SERVER_PORT:-5000} --host ${SERVER_HOST:-0.0.0.0} --noPrependStageInUrl", 19 | "lambda:deploy": "yarn run lambda:sls deploy", 20 | "lambda:info": "yarn run lambda:sls info", 21 | "lambda:logs": "yarn run lambda:sls logs", 22 | "lambda:metrics": "yarn run lambda:sls metrics", 23 | "lambda:rollback": "yarn run lambda:sls rollback", 24 | "lambda:_delete": "yarn run lambda:sls remove" 25 | }, 26 | "dependencies": { 27 | "date-fns": "^2.29.3", 28 | "express": "^4.17.3", 29 | "gray-matter": "^4.0.2", 30 | "next": "^13.1.1", 31 | "react": "^18.2.0", 32 | "react-dom": "^18.2.0", 33 | "remark": "^13.0.0", 34 | "remark-html": "^13.0.1", 35 | "serverless-http": "^2.7.0" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.20.7", 39 | "@babel/eslint-parser": "^7.19.1", 40 | "@babel/preset-react": "^7.18.6", 41 | "adm-zip": "^0.5.10", 42 | "eslint": "^8.30.0", 43 | "eslint-config-formidable": "^4.0.0", 44 | "eslint-plugin-filenames": "^1.3.2", 45 | "eslint-plugin-import": "^2.22.1", 46 | "eslint-plugin-promise": "^6.1.1", 47 | "eslint-plugin-react": "^7.31.11", 48 | "serverless": "^2.35.0", 49 | "serverless-jetpack": "^0.11.1", 50 | "serverless-offline": "^6.9.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/nextjs-serverless-demo/e494a301b6066394c22f39fa9c371a9573f42725/public/favicon.ico -------------------------------------------------------------------------------- /public/images/formidable-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/nextjs-serverless-demo/e494a301b6066394c22f39fa9c371a9573f42725/public/images/formidable-logo.png -------------------------------------------------------------------------------- /scripts/compare-nft.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * Compare our tracing outputs vs. Vercel's nft tracing outputs. 5 | * 6 | * Usage: 7 | * 8 | * ```sh 9 | * # Build / package everything. 10 | * $ yarn clean && yarn build 11 | * $ yarn lambda:sls package 12 | * 13 | * # Run comparison. 14 | * $ node ./scripts/compare-nft.js 15 | * ``` 16 | * 17 | * Note: We don't actually have real deps, we're just brittly using what's already found in 18 | * `node_modules` :) 19 | */ 20 | const fs = require("fs").promises; 21 | const path = require("path"); 22 | const globby = require("globby"); 23 | const AdmZip = require("adm-zip"); 24 | const chalk = require("chalk"); 25 | 26 | const cwd = path.resolve(__dirname, ".."); 27 | 28 | // ============================================================================ 29 | // Helpers 30 | // ============================================================================ 31 | const { log, error } = console; 32 | const difference = (s1, s2) => new Set([...s1].filter((x) => !s2.has(x))); 33 | 34 | const zipContents = (zipPath) => { 35 | const zip = new AdmZip(zipPath); 36 | return zip.getEntries().map(({ entryName }) => entryName); 37 | }; 38 | 39 | 40 | // Needs `yarn build` 41 | const getNftFiles = async () => { 42 | const nfts = await globby([".next/**/*.nft.json"], { cwd }); 43 | const allFiles = new Set(); 44 | 45 | for (const nft of nfts) { 46 | const dir = path.resolve(cwd, path.dirname(nft)); 47 | const { files } = JSON.parse((await fs.readFile(path.resolve(cwd, nft))).toString()); 48 | for (const file of files) { 49 | allFiles.add(path.relative(cwd, path.resolve(dir, file))); 50 | } 51 | } 52 | 53 | return allFiles; 54 | }; 55 | 56 | // Needs `yarn lambda:sls package` after `yarn build` 57 | const getTraceFiles = async () => { 58 | const files = zipContents(path.resolve(cwd, ".serverless/blog.zip")); 59 | return new Set(files); 60 | }; 61 | 62 | // ============================================================================ 63 | // Script 64 | // ============================================================================ 65 | const cli = async () => { 66 | const nftFiles = await getNftFiles(); 67 | const traceFiles = await getTraceFiles(); 68 | 69 | const missingInNft = Array.from(difference(traceFiles, nftFiles)).sort(); 70 | const missingInTrace = Array.from(difference(nftFiles, traceFiles)).sort(); 71 | 72 | log(chalk ` 73 | {cyan ## Stats} 74 | 75 | * Entries: 76 | * NFT: {gray ${nftFiles.size}} 77 | * Trace: {gray ${traceFiles.size}} 78 | 79 | {cyan ## Differences} 80 | {green ## Missing in NFT} ({gray ${missingInNft.length}}) 81 | ${missingInNft.map((m) => `- ${m}`).join("\n")} 82 | 83 | {green ## Missing in Trace} ({gray ${missingInTrace.length}}) 84 | ${missingInTrace.map((m) => `- ${m}`).join("\n")} 85 | `); 86 | }; 87 | 88 | if (require.main === module) { 89 | cli().catch((err) => { 90 | error(err); 91 | process.exit(1); // eslint-disable-line no-process-exit 92 | }); 93 | } 94 | 95 | module.exports = { 96 | cli 97 | }; 98 | -------------------------------------------------------------------------------- /server/blog.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { parse } = require("url"); 4 | const path = require("path"); 5 | const express = require("express"); 6 | const NextNodeServer = require("next/dist/server/next-server").default; 7 | const { defaultConfig } = require("next/dist/server/config-shared"); 8 | const nextConfig = require("../next.config"); 9 | 10 | const DEFAULT_PORT = 4000; 11 | const PORT = parseInt(process.env.SERVER_PORT || DEFAULT_PORT, 10); 12 | const HOST = process.env.SERVER_HOST || "0.0.0.0"; 13 | const JSON_INDENT = 2; 14 | 15 | // Create the server app. 16 | const getApp = async () => { 17 | // Get server config. 18 | // TODO(18): Do full Next.js configuration mutations. 19 | // Our simple assign() here only works because our next.config.js 20 | // is a simple object with object values. 21 | // https://github.com/FormidableLabs/nextjs-serverless-demo/issues/18 22 | const serverConfig = Object.assign({}, defaultConfig, nextConfig); 23 | 24 | // Set up Next.js server. 25 | // We use the trace output generated Server file as our model from Next.js: 26 | // https://unpkg.com/browse/next/dist/build/utils.js 27 | // See `copyTracedFiles()` and outputted server. 28 | const nextApp = new NextNodeServer({ 29 | dev: false, 30 | dir: path.resolve(__dirname, ".."), 31 | conf: { 32 | ...serverConfig, 33 | distDir: "./.next" // relative to `dir` 34 | } 35 | }); 36 | await nextApp.prepare(); 37 | const nextHandler = nextApp.getRequestHandler(); 38 | 39 | // Stage, base path stuff. 40 | const app = express(); 41 | 42 | // Development tweaks. 43 | app.set("json spaces", JSON_INDENT); 44 | 45 | // Add here for `/blog/images/**` 46 | app.use(express.static("public")); 47 | 48 | // Page handlers, 49 | app.use((req, res) => { 50 | const parsedUrl = parse(req.url, true); 51 | return nextHandler(req, res, parsedUrl); 52 | }); 53 | 54 | return app; 55 | }; 56 | 57 | // LAMBDA: Export handler for lambda use. 58 | let handler; 59 | module.exports.handler = async (event, context) => { 60 | // Lazy require `serverless-http` to allow non-Lambda targets to omit. 61 | // eslint-disable-next-line global-require 62 | handler = handler || require("serverless-http")( 63 | await getApp(), 64 | // TODO(STATIC): Again, shouldn't be serving images from the Lambda :) 65 | { 66 | binary: ["image/*"] 67 | } 68 | ); 69 | 70 | return handler(event, context); 71 | }; 72 | 73 | // DOCKER/DEV/ANYTHING: Start the server directly. 74 | if (require.main === module) { 75 | (async () => { 76 | const server = (await getApp()).listen({ 77 | port: PORT, 78 | host: HOST 79 | }, () => { 80 | const { address, port } = server.address(); 81 | 82 | // eslint-disable-next-line no-console 83 | console.log(`Server started at http://${address}:${port}`); 84 | }); 85 | })(); 86 | } 87 | -------------------------------------------------------------------------------- /server/root.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const express = require("express"); 4 | 5 | const DEFAULT_PORT = 4000; 6 | const PORT = parseInt(process.env.SERVER_PORT || DEFAULT_PORT, 10); 7 | const HOST = process.env.SERVER_HOST || "0.0.0.0"; 8 | const JSON_INDENT = 2; 9 | 10 | // Create the server app. 11 | const getApp = async () => { 12 | // Stage, base path stuff. 13 | const app = express(); 14 | 15 | // Development tweaks. 16 | app.set("json spaces", JSON_INDENT); 17 | 18 | // Add here for `/favicon.ico` 19 | app.use("/favicon.ico", express.static("public/favicon.ico")); 20 | 21 | // Page handlers, 22 | app.get("/", (req, res) => res.json({ 23 | msg: "Root handler. Check out /blog for more!" 24 | })); 25 | 26 | return app; 27 | }; 28 | 29 | // LAMBDA: Export handler for lambda use. 30 | let handler; 31 | module.exports.handler = async (event, context) => { 32 | // Lazy require `serverless-http` to allow non-Lambda targets to omit. 33 | // eslint-disable-next-line global-require 34 | handler = handler || require("serverless-http")( 35 | await getApp(), 36 | // TODO(STATIC): Again, shouldn't be serving images from the Lambda :) 37 | { 38 | binary: ["image/*"] 39 | } 40 | ); 41 | 42 | return handler(event, context); 43 | }; 44 | 45 | // DOCKER/DEV/ANYTHING: Start the server directly. 46 | if (require.main === module) { 47 | (async () => { 48 | const server = (await getApp()).listen({ 49 | port: PORT, 50 | host: HOST 51 | }, () => { 52 | const { address, port } = server.address(); 53 | 54 | // eslint-disable-next-line no-console 55 | console.log(`Server started at http://${address}:${port}`); 56 | }); 57 | })(); 58 | } 59 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | # CloudFormation output name: `sls-${SERVICE_NAME}-${STAGE}` 2 | service: sls-${self:custom.service} 3 | 4 | package: 5 | individually: true 6 | 7 | custom: 8 | service: ${env:SERVICE_NAME} 9 | region: ${opt:region, env:AWS_REGION} 10 | stage: ${opt:stage, env:STAGE} 11 | jetpack: 12 | preInclude: 13 | - "!**" # Start with no files at all. 14 | trace: 15 | ignores: 16 | # Provided on Lambda 17 | - "aws-sdk" 18 | 19 | # Stuff Next.js trace mode ignores. 20 | # https://github.com/vercel/next.js/blob/ab40370ea5b69aa4dd601907eb85d25da1140b6b/packages/next/build/index.ts#L1257-L1280 21 | # https://unpkg.com/browse/next@12.1.0/dist/build/index.js (L765-L777) 22 | # https://github.com/vercel/next.js/blob/v13.0.2/packages/next/build/index.ts#L1807-L1823 23 | - "next/dist/compiled/webpack/bundle5" 24 | - "webpack5" 25 | 26 | # Ignore real deps in Next.js that we shouldn't need at runtime. 27 | - "caniuse-lite" 28 | - "postcss" 29 | - "webpack" 30 | 31 | allowMissing: 32 | "node-fetch": 33 | - "encoding" 34 | "next": 35 | - "critters" # for CSS optimization 36 | - "pnpapi" # for PnP usage 37 | - "fibers" # Part of sass-loader 38 | - "@next/font" # Optional next font loading 39 | # If using `experimental.nextScriptWorkers = true` you'll need to remove 40 | # this allowMissing and `yarn add @builder.io/partytown` 41 | # See: https://nextjs.org/docs/basic-features/script#off-loading-scripts-to-a-web-worker-experimental 42 | - "@builder.io/partytown" 43 | # ... and here too for partytown. 44 | "./.next/server/pages/_document.js": 45 | - "@builder.io/partytown" 46 | 47 | dynamic: 48 | resolutions: 49 | # Sources 50 | # ------- 51 | # Our servers do lazy requires of pages. 52 | "./server/blog.js": [] 53 | "./server/root.js": [] 54 | 55 | # Built 56 | # ----- 57 | # Webpack chunk loading 58 | # .next/server/webpack-api-runtime.js [131:27]: require("./chunks/" + __webpack_require__.u(chunkId)) 59 | # .next/server/webpack-runtime.js [143:27]: require("./chunks/" + __webpack_require__.u(chunkId)) 60 | "./.next/server/webpack-api-runtime.js": [] 61 | "./.next/server/webpack-runtime.js": [] 62 | 63 | # Dependencies 64 | # ------------ 65 | # express/lib/view.js [81:13]: require(mod) 66 | "express/lib/view.js": [] 67 | 68 | # Optional optimizer. 69 | # next/dist/compiled/@ampproject/toolbox-optimizer/index.js [1:21249]: require.resolve(e) 70 | "next/dist/compiled/@ampproject/toolbox-optimizer/index.js": [] 71 | 72 | # Webpack plugin tool. 73 | # next/dist/compiled/cssnano-simple/index.js [1:21238]: require.resolve(name,{paths:[".",ctx.path]}) 74 | # next/dist/compiled/cssnano-simple/index.js [1:21676]: require(__nccwpck_require2_(8440).resolve(path.join(v,"browserslist-stats.json"),{paths:["."]})) 75 | # next/dist/compiled/cssnano-simple/index.js [1:22728]: require("caniuse-lite/data/regions/"+y+".js") 76 | # next/dist/compiled/cssnano-simple/index.js [1:22968]: require("caniuse-lite/data/features/"+v+".js") 77 | "next/dist/compiled/cssnano-simple/index.js": [] 78 | 79 | # Dynamic require function 80 | # From: https://github.com/vercel/edge-runtime/blob/cec1f8493e14465692eec5f672cdb916b51e8e84/packages/vm/src/require.ts#L30-L77 81 | # next/dist/compiled/edge-runtime/index.js [1:6716]: require.resolve(c,{paths:[(0,s.dirname)(a)]}) 82 | "next/dist/compiled/edge-runtime/index.js": [] 83 | 84 | # next/dist/compiled/jest-worker/index.js [1:7889]: require.resolve(e) 85 | # next/dist/compiled/jest-worker/index.js [1:22455]: require(e) 86 | "next/dist/compiled/jest-worker/index.js": [] 87 | 88 | # Next13 next-server adds in a webpack / Node.js patching hook (ugh!) as well as 89 | # compiled-in versions of libraries that we need to address. 90 | # See 91 | # - https://github.com/vercel/next.js/blob/v13.0.2/packages/next/server/next-server.ts#L1 92 | # - https://github.com/vercel/next.js/blob/v13.0.2/packages/next/build/webpack/require-hook.ts 93 | # 94 | # Note diff from: 95 | # - https://github.com/vercel/next.js/blob/v12.3.2/packages/next/server/next-server.ts 96 | # These libraries require root "react-dom" which now should have the built-in index. 97 | "next/dist/compiled/react-dom/cjs/react-dom-server-legacy.browser.development.js": 98 | - "next/dist/compiled/react-dom/index.js" 99 | "next/dist/compiled/react-dom/cjs/react-dom-server-legacy.browser.production.min.js": 100 | - "next/dist/compiled/react-dom/index.js" 101 | 102 | # Dynamically look up package versions. 103 | # next/dist/lib/get-package-version.js [88:27]: require.resolve(`${name}/package.json`, { 104 | "next/dist/lib/get-package-version.js": [] 105 | 106 | # Webpack build time replacements in `loadWebpackHook` 107 | # next/dist/server/config-utils.js [188:12]: require.resolve(replacement) 108 | "next/dist/server/config-utils.js": [] 109 | 110 | # Dynamically require config path provided by user. 111 | # next/dist/server/config.js [68:35]: require(path) 112 | # next/dist/server/config.js [70:41]: import((0, _url).pathToFileURL(path).href) 113 | "next/dist/server/config.js": [] 114 | 115 | # next/dist/server/image-optimizer.js [54:12]: require(process.env.NEXT_SHARP_PATH || "sharp") 116 | "next/dist/server/image-optimizer.js": [] 117 | 118 | # next/dist/server/lib/incremental-cache/index.js [29:30]: require(incrementalCacheHandlerPath) 119 | "next/dist/server/lib/incremental-cache/index.js": [] 120 | 121 | # next/dist/server/load-components.js [22:23]: require((0, _path).join(distDir, `fallback-${_constants.BUILD_MANIFEST}`)) 122 | # next/dist/server/load-components.js [39:8]: require((0, _path).join(distDir, _constants.BUILD_MANIFEST)) 123 | # next/dist/server/load-components.js [40:8]: require((0, _path).join(distDir, _constants.REACT_LOADABLE_MANIFEST)) 124 | # next/dist/server/load-components.js [41:30]: require((0, _path).join(distDir, "server", _constants.FLIGHT_MANIFEST + ".json")) 125 | "next/dist/server/load-components.js": [] 126 | 127 | # Runtime build directory imports. 128 | # next/dist/server/next-server.js [149:15]: require((0, _path).join(this.serverDistDir, _constants.PAGES_MANIFEST)) 129 | # next/dist/server/next-server.js [154:19]: require(appPathsManifestPath) 130 | # next/dist/server/next-server.js [469:33]: require(builtPagePath) 131 | # next/dist/server/next-server.js [587:15]: require((0, _path).join(this.distDir, "server", _constants.FLIGHT_MANIFEST + ".json")) 132 | # next/dist/server/next-server.js [591:15]: require((0, _path).join(this.distDir, "server", _constants.FLIGHT_SERVER_CSS_MANIFEST + ".json")) 133 | # next/dist/server/next-server.js [595:15]: require((0, _path).join(this.distDir, "server", `${_constants.FONT_LOADER_MANIFEST}.json`)) 134 | # next/dist/server/next-server.js [988:25]: require((0, _path).join(this.serverDistDir, _constants.MIDDLEWARE_MANIFEST)) 135 | # next/dist/server/next-server.js [1018:25]: require((0, _path).join(this.serverDistDir, _constants.MIDDLEWARE_MANIFEST)) 136 | # next/dist/server/next-server.js [1341:25]: require((0, _path).join(this.distDir, _constants.PRERENDER_MANIFEST)) 137 | # next/dist/server/next-server.js [1345:15]: require((0, _path).join(this.distDir, _constants.ROUTES_MANIFEST)) 138 | "next/dist/server/next-server.js": [] 139 | 140 | # next/dist/server/require.js [39:27]: require((0, _path).join(serverBuildPath, _constants.APP_PATHS_MANIFEST)) 141 | # next/dist/server/require.js [41:26]: require((0, _path).join(serverBuildPath, _constants.PAGES_MANIFEST)) 142 | # next/dist/server/require.js [88:11]: require(pagePath) 143 | # next/dist/server/require.js [92:25]: require((0, _path).join(serverBuildPath, _constants.FONT_MANIFEST)) 144 | "next/dist/server/require.js": [] 145 | 146 | plugins: 147 | - serverless-jetpack 148 | - serverless-offline 149 | 150 | provider: 151 | name: aws 152 | 153 | # Lambda configuration 154 | runtime: nodejs16.x 155 | timeout: 30 # seconds (`300` max) 156 | memorySize: 1024 # MB value (`1024` default) 157 | 158 | # Deployment / environment configuration 159 | region: ${self:custom.region} 160 | stage: ${self:custom.stage} 161 | environment: 162 | STAGE: ${self:custom.stage} 163 | SERVICE_NAME: ${self:custom.service} 164 | NODE_ENV: production 165 | 166 | # AWS Resource Tags 167 | stackTags: # For CF stack 168 | Stage: ${self:custom.stage} 169 | Service: ${self:custom.service} 170 | tags: # For resources 171 | Stage: ${self:custom.stage} 172 | Service: ${self:custom.service} 173 | 174 | # TODO(STATIC): Allows serving of binary media. Shouldn't use this for real. 175 | apiGateway: 176 | binaryMediaTypes: 177 | - "*/*" 178 | 179 | functions: 180 | # SCENARIO - base: The simplest, vanilla Serverless app. 181 | blog: 182 | handler: server/blog.handler 183 | environment: 184 | BASE_PATH: /blog 185 | events: # Use a generic proxy to allow Express app to route. 186 | - http: ANY /blog 187 | - http: 'ANY /blog/{proxy+}' 188 | jetpack: 189 | trace: 190 | include: 191 | # Next.js config/generated files: gather dependencies 192 | - "next.config.js" 193 | - ".next/server/**/*.js" 194 | package: 195 | include: 196 | # Raw data for posts is read from disk outside `.next` build directory. 197 | - "src/posts/**/*.md" 198 | # Needed built Next.js assets and info. (Some of these are also traced). 199 | - ".next/BUILD_ID" 200 | - ".next/*.json" 201 | - ".next/server/**" 202 | # Ignore all NFT files. 203 | - "!**/*.nft.json" 204 | # TODO(STATIC): Should be served outside Lambda in real production. 205 | - ".next/static/**" 206 | - "public/images/**" 207 | 208 | # TODO(STATIC): Simple static server for root assets. 209 | root: 210 | handler: server/root.handler 211 | environment: 212 | BASE_PATH: / 213 | events: # Use a generic proxy to allow Express app to route. 214 | - http: ANY / 215 | - http: 'ANY /{proxy+}' 216 | package: 217 | include: 218 | # TODO(STATIC): Should be served outside Lambda in real production. 219 | # Only favicon is served from root handler. 220 | - "public/favicon.ico" 221 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - formidable/configurations/es6-react 4 | 5 | parser: "@babel/eslint-parser" 6 | parserOptions: 7 | requireConfigFile: false 8 | babelOptions: 9 | presets: ["@babel/preset-react"] 10 | 11 | rules: 12 | react/prop-types: "off" 13 | filenames/match-regex: "off" 14 | -------------------------------------------------------------------------------- /src/components/date.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { parseISO, format } from "date-fns"; 3 | 4 | export default function Date({ dateString }) { 5 | const date = parseISO(dateString); 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/layout.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Head from "next/head"; 3 | import styles from "./layout.module.css"; 4 | import utilStyles from "../styles/utils.module.css"; 5 | import Link from "next/link"; 6 | 7 | const name = "Next.js on AWS Lambda"; 8 | export const siteTitle = "Next.js on AWS Lambda"; 9 | const logoSrc = "/blog/images/formidable-logo.png"; 10 | // eslint-disable-next-line max-len 11 | const ogImgSrc = `https://og-image.now.sh/${encodeURI(siteTitle)}.png?theme=light&md=1&fontSize=100px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fnextjs-black-logo.svg`; 12 | 13 | export default function Layout({ children, home }) { 14 | return ( 15 |
16 | 17 | 18 | 22 | 26 | 27 | 28 | 29 |
30 | {home ? ( 31 | <> 32 | {name} 37 |

{name}

38 | 39 | ) : ( 40 | <> 41 | 42 | {name} 47 | 48 |

49 | 50 | {name} 51 | 52 |

53 | 54 | )} 55 |
56 |
{children}
57 | {!home && ( 58 |
59 | 60 | ← Back to home 61 | 62 |
63 | )} 64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/components/layout.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 36rem; 3 | padding: 0 1rem; 4 | margin: 3rem auto 6rem; 5 | } 6 | 7 | .header { 8 | display: flex; 9 | flex-direction: column; 10 | align-items: center; 11 | } 12 | 13 | .headerImage { 14 | width: 6rem; 15 | height: 6rem; 16 | } 17 | 18 | .headerHomeImage { 19 | width: 8rem; 20 | height: 8rem; 21 | } 22 | 23 | .backToHome { 24 | margin: 3rem 0 0; 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/posts.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-style */ 2 | import fs from "fs"; 3 | import { promisify } from "util"; 4 | import path from "path"; 5 | import matter from "gray-matter"; 6 | import remark from "remark"; 7 | import html from "remark-html"; 8 | 9 | const readdir = promisify(fs.readdir); 10 | const readFile = promisify(fs.readFile); 11 | const postsDirectory = path.resolve(process.cwd(), "src/posts"); 12 | 13 | export async function getSortedPostsData() { 14 | // Get file names under /posts 15 | const fileNames = await readdir(postsDirectory); 16 | const allPostsData = await Promise.all(fileNames.map(async (fileName) => { 17 | // Remove ".md" from file name to get id 18 | const id = fileName.replace(/\.md$/, ""); 19 | 20 | // Read markdown file as string 21 | const fullPath = path.join(postsDirectory, fileName); 22 | const fileContents = await readFile(fullPath, "utf8"); 23 | 24 | // Use gray-matter to parse the post metadata section 25 | const matterResult = matter(fileContents); 26 | 27 | // Combine the data with the id 28 | return { 29 | id, 30 | ...matterResult.data 31 | }; 32 | })); 33 | // Sort posts by date 34 | return allPostsData.sort((a, b) => { 35 | if (a.date < b.date) { 36 | return 1; 37 | } 38 | return -1; 39 | }); 40 | } 41 | 42 | export async function getAllPostIds() { 43 | const fileNames = await readdir(postsDirectory); 44 | return fileNames.map((fileName) => ({ 45 | params: { 46 | id: fileName.replace(/\.md$/, "") 47 | } 48 | })); 49 | } 50 | 51 | export async function getPostData(id) { 52 | const fullPath = path.join(postsDirectory, `${id}.md`); 53 | const fileContents = await readFile(fullPath, "utf8"); 54 | 55 | // Use gray-matter to parse the post metadata section 56 | const matterResult = matter(fileContents); 57 | 58 | // Use remark to convert markdown into HTML string 59 | const processedContent = await remark() 60 | .use(html) 61 | .process(matterResult.content); 62 | const contentHtml = processedContent.toString(); 63 | 64 | // Combine the data with the id and contentHtml 65 | return { 66 | id, 67 | contentHtml, 68 | ...matterResult.data 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "../styles/global.css"; 3 | 4 | export default function App({ Component, pageProps }) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/api/hello.js: -------------------------------------------------------------------------------- 1 | export default function handler(req, res) { 2 | // eslint-disable-next-line no-magic-numbers 3 | res.status(200).json({ text: "Hello" }); 4 | } 5 | -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-style */ 2 | import React from "react"; 3 | import Head from "next/head"; 4 | import Layout, { siteTitle } from "../components/layout"; 5 | import utilStyles from "../styles/utils.module.css"; 6 | import { getSortedPostsData } from "../lib/posts"; 7 | import Link from "next/link"; 8 | import Date from "../components/date"; 9 | 10 | export default function Home({ allPostsData }) { 11 | return ( 12 | 13 | 14 | {siteTitle} 15 | 16 |
17 |

18 | A sample blog built with Next.js and deployed to AWS lambda as a 19 | single function / endpoint via the Next.js server{" "} 20 | output target. 21 |

22 |

23 | Learn more at:{" "} 24 | FormidableLabs/nextjs-serverless-demo 27 |

28 |
29 |
30 |

Blog

31 |
    32 | {allPostsData.map(({ id, date, title }) => ( 33 |
  • 34 | {/* */} 35 | 36 | {title} 37 | 38 |
    39 | 40 | 41 | 42 |
  • 43 | ))} 44 |
45 |
46 |
47 | ); 48 | } 49 | 50 | export async function getStaticProps() { 51 | const allPostsData = await getSortedPostsData(); 52 | return { 53 | props: { 54 | allPostsData 55 | } 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/pages/posts/[id].js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-style */ 2 | import React from "react"; 3 | import Layout from "../../components/layout"; 4 | import { getAllPostIds, getPostData } from "../../lib/posts"; 5 | import Head from "next/head"; 6 | import Date from "../../components/date"; 7 | import utilStyles from "../../styles/utils.module.css"; 8 | 9 | export default function Post({ postData }) { 10 | return ( 11 | 12 | 13 | {postData.title} 14 | 15 |
16 |

{postData.title}

17 |
18 | 19 |
20 |
21 |
22 |
23 | ); 24 | } 25 | 26 | export async function getStaticPaths() { 27 | const paths = await getAllPostIds(); 28 | return { 29 | paths, 30 | fallback: false 31 | }; 32 | } 33 | 34 | export async function getStaticProps({ params }) { 35 | const postData = await getPostData(params.id); 36 | return { 37 | props: { 38 | postData 39 | } 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/posts/pre-rendering.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Two Forms of Pre-rendering" 3 | date: "2020-01-01" 4 | --- 5 | 6 | Next.js has two forms of pre-rendering: **Static Generation** and **Server-side Rendering**. The difference is in **when** it generates the HTML for a page. 7 | 8 | - **Static Generation** is the pre-rendering method that generates the HTML at **build time**. The pre-rendered HTML is then _reused_ on each request. 9 | - **Server-side Rendering** is the pre-rendering method that generates the HTML on **each request**. 10 | 11 | Importantly, Next.js lets you **choose** which pre-rendering form to use for each page. You can create a "hybrid" Next.js app by using Static Generation for most pages and using Server-side Rendering for others. -------------------------------------------------------------------------------- /src/posts/ssg-ssr.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "When to Use Static Generation v.s. Server-side Rendering" 3 | date: "2020-01-02" 4 | --- 5 | 6 | We recommend using **Static Generation** (with and without data) whenever possible because your page can be built once and served by CDN, which makes it much faster than having a server render the page on every request. 7 | 8 | You can use Static Generation for many types of pages, including: 9 | 10 | - Marketing pages 11 | - Blog posts 12 | - E-commerce product listings 13 | - Help and documentation 14 | 15 | You should ask yourself: "Can I pre-render this page **ahead** of a user's request?" If the answer is yes, then you should choose Static Generation. 16 | 17 | On the other hand, Static Generation is **not** a good idea if you cannot pre-render a page ahead of a user's request. Maybe your page shows frequently updated data, and the page content changes on every request. 18 | 19 | In that case, you can use **Server-Side Rendering**. It will be slower, but the pre-rendered page will always be up-to-date. Or you can skip pre-rendering and use client-side JavaScript to populate data. -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | line-height: 1.6; 8 | font-size: 18px; 9 | } 10 | 11 | * { 12 | box-sizing: border-box; 13 | } 14 | 15 | a { 16 | color: #0070f3; 17 | text-decoration: none; 18 | } 19 | 20 | a:hover { 21 | text-decoration: underline; 22 | } 23 | 24 | img { 25 | max-width: 100%; 26 | display: block; 27 | } 28 | -------------------------------------------------------------------------------- /src/styles/utils.module.css: -------------------------------------------------------------------------------- 1 | .heading2Xl { 2 | font-size: 2.5rem; 3 | line-height: 1.2; 4 | font-weight: 800; 5 | letter-spacing: -0.05rem; 6 | margin: 1rem 0; 7 | } 8 | 9 | .headingXl { 10 | font-size: 2rem; 11 | line-height: 1.3; 12 | font-weight: 800; 13 | letter-spacing: -0.05rem; 14 | margin: 1rem 0; 15 | } 16 | 17 | .headingLg { 18 | font-size: 1.5rem; 19 | line-height: 1.4; 20 | margin: 1rem 0; 21 | } 22 | 23 | .headingMd { 24 | font-size: 1.2rem; 25 | line-height: 1.5; 26 | } 27 | 28 | .borderCircle { 29 | border-radius: 9999px; 30 | } 31 | 32 | .colorInherit { 33 | color: inherit; 34 | } 35 | 36 | .padding1px { 37 | padding-top: 1px; 38 | } 39 | 40 | .list { 41 | list-style: none; 42 | padding: 0; 43 | margin: 0; 44 | } 45 | 46 | .listItem { 47 | margin: 0 0 1.25rem; 48 | } 49 | 50 | .lightText { 51 | color: #666; 52 | } 53 | --------------------------------------------------------------------------------