├── .env.development ├── .env.production ├── .gitignore ├── Procfile ├── README.md ├── cdk.json ├── cron-jobs.js ├── docker-compose.yml ├── infra ├── ExampleStack.js └── example-app.js ├── jobs ├── .babelrc.js ├── checkTwitter.js └── generatePdf.js ├── jsconfig.json ├── lib ├── execJob.js ├── privateConf.js └── publishMessage.js ├── package.json ├── pages ├── api │ ├── __devPublishMessage.js │ └── publishMessage.js └── index.js ├── public └── favicon.ico └── yarn.lock /.env.development: -------------------------------------------------------------------------------- 1 | # This file is for default dev environment variables 2 | # Use .env.local files to store dev environment secrets 3 | # See https://nextjs.org/docs/basic-features/environment-variables 4 | 5 | # APP 6 | APP_ENV=development 7 | DOMAIN=localhost:3000 8 | PROTOCOL=http -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # When deploying on Vercel, set those env variables through their UI directly 2 | 3 | # APP 4 | APP_ENV=production 5 | PROTOCOL=https 6 | 7 | # AWS 8 | # Required for accessing AWS API from Vercel 9 | # You cannot set AWS_ACCESS_KEY_ID env variables, 10 | # see https://vercel.com/docs/platform/limits#reserved-variables 11 | # SECRET_AWS_ACCESS_KEY_ID= 12 | # SECRET_AWS_SECRET_ACCESS_KEY= 13 | # SECRET_AWS_REGION= 14 | # SECRET_AWS_ACCOUNT_ID= 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # envs 37 | .env.local 38 | 39 | # cdk 40 | cdk.out 41 | 42 | # db 43 | postgres-data 44 | 45 | # misc 46 | TODO 47 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: yarn dev 2 | docker: docker-compose up 3 | cron: yarn babel-node --presets @babel/preset-env cron-jobs.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nextjs-vercel-aws-cdk-example [![Mentioned in Awesome CDK](https://awesome.re/mentioned-badge.svg)](https://github.com/eladb/awesome-cdk) 2 | 3 | _Companion project of the article at https://dev.to/vvo/coding-the-jamstack-missing-parts-databases-crons-background-jobs-1bpj_ 4 | 5 | This is a Next.js example coupled with AWS services to provide: 6 | - database ([RDS](https://aws.amazon.com/rds/)) 7 | - cron jobs ([EventBridge](https://aws.amazon.com/eventbridge/) + [Lambda](https://aws.amazon.com/lambda/)) 8 | - background jobs ([SNS](https://aws.amazon.com/sns/) + [Lambda](https://aws.amazon.com/lambda/)) 9 | 10 | The goal is to have Next.js being deployed on Vercel, with resources being deployed on AWS via [AWS Cloud Development Kit](https://aws.amazon.com/cdk/). The AWS stack is described and deployed via a single JavaScript file ([infra/ExampleStack.js](./infra/ExampleStack.js)) thanks to AWS CDK's infrastructure as code (IaC) features. 11 | 12 | This example provides local tools to replicate the AWS services in development mode. 13 | 14 | ## Requirements 15 | 16 | - Node.js via nvm: https://github.com/nvm-sh/nvm#installing-and-updating 17 | - Yarn: https://classic.yarnpkg.com/en/docs/install#alternatives-stable 18 | - An AWS account: https://portal.aws.amazon.com/billing/signup 19 | - Installed and configured AWS CLI: https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html 20 | - [Docker Desktop](https://www.docker.com/products/docker-desktop) for ease of running a local database 21 | - [overmind](https://github.com/DarthSim/overmind) for ease of running multiple local services 22 | 23 | ## How to use 24 | 25 | 1. Fork this repository, add it to your Vercel projects, deploy it 26 | 2. Clone the repository locally and run 27 | 28 | ```bash 29 | yarn 30 | yarn cdk deploy # first run can be long 31 | ``` 32 | 33 | This will deploy the services to AWS. 34 | 35 | 3. Add necessary env variables to your Vercel project: 36 | 37 | You need to add `SECRET_AWS_ACCESS_KEY_ID`, `SECRET_AWS_SECRET_ACCESS_KEY` and `SECRET_AWS_REGION` to Vercel, see https://vercel.com/blog/environment-variables-ui on how to do it. 38 | 39 | 1. Verify it's working: 40 | 41 | You can check in CloudWatch logs at https://console.aws.amazon.com/cloudwatch/home that your functions are called: 42 | - every 5 minutes for jobs/checkTwitter.js 43 | - when you click on the "Generate Pdf" button in your production Vercel application for jobs/generatePdf.js. 44 | 45 | ## Local development 46 | 47 | In local development, all you have to do is: 48 | 49 | ```bash 50 | overmind start 51 | ``` 52 | 53 | By reading the [Procfile](./Procfile) This will start: 54 | - The Next.js app in development mode 55 | - The PostgreSQL database via Docker 56 | - The cron jobs defined in [cron-jobs.js](./cron-jobs.js) 57 | 58 | ## Tips and docs 59 | 60 | - Once your database is deployed, you can get its endpoint and credentials via https://console.aws.amazon.com/rds/home and https://console.aws.amazon.com/secretsmanager/home 61 | - If your stack is in an unstable situation (cannot deploy or destroy), then go to https://console.aws.amazon.com/cloudformation and delete it manually. 62 | - CDK API: https://docs.aws.amazon.com/cdk/api/latest/docs/aws-construct-library.html 63 | - CDK guide: https://docs.aws.amazon.com/cdk/latest/guide/home.html 64 | - CDK command line: https://docs.aws.amazon.com/cdk/latest/guide/cli.html 65 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "babel-node --presets @babel/preset-env infra/example-app.js", 3 | "context": { 4 | "@aws-cdk/core:enableStackNameDuplicates": "true", 5 | "aws-cdk:enableDiffNoFail": "true" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /cron-jobs.js: -------------------------------------------------------------------------------- 1 | import cron from "cron"; 2 | import execJob from "./lib/execJob"; 3 | 4 | // add more cron jobs here 5 | const cronJobs = { 6 | "*/10 * * * * *": "checkTwitter", // check twitter every 10 seconds 7 | }; 8 | 9 | // let's iterate over all cron jobs and start them 10 | Object.entries(cronJobs).forEach(([cronExpression, jobName]) => { 11 | cron 12 | .job(cronExpression, function () { 13 | execJob(jobName); 14 | }) 15 | .start(); 16 | }); 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | db: 4 | image: postgres:12.3-alpine # Be sure to always use the same version locally and on AWS 5 | ports: 6 | - "54320:5432" 7 | environment: 8 | POSTGRES_USER: example 9 | POSTGRES_PASSWORD: example 10 | POSTGRES_DB: example 11 | volumes: 12 | - ./postgres-data:/var/lib/postgresql/data 13 | command: ["postgres", "-c", "log_statement=all"] # Log SQL queries to stdout 14 | -------------------------------------------------------------------------------- /infra/ExampleStack.js: -------------------------------------------------------------------------------- 1 | import * as sns from "@aws-cdk/aws-sns"; 2 | import * as subscriptions from "@aws-cdk/aws-sns-subscriptions"; 3 | import * as cdk from "@aws-cdk/core"; 4 | import * as events from "@aws-cdk/aws-events"; 5 | import * as targets from "@aws-cdk/aws-events-targets"; 6 | import * as rds from "@aws-cdk/aws-rds"; 7 | import * as ec2 from "@aws-cdk/aws-ec2"; 8 | 9 | import { NodejsFunction } from "aws-lambda-nodejs-webpack"; 10 | 11 | export default class ExampleStack extends cdk.Stack { 12 | constructor(scope, id, props) { 13 | super(scope, id, props); 14 | 15 | // define lambdas 16 | const defaultLambdaOptions = { 17 | timeout: cdk.Duration.seconds(30), 18 | // Here you can pass any environment variable you'd like to be available in your Lambda 19 | environment: { 20 | APP_ENV: process.env.APP_ENV, 21 | }, 22 | }; 23 | 24 | const generatePdfLambda = new NodejsFunction(this, "generatePdfLambda", { 25 | entry: "jobs/generatePdf.js", 26 | handler: "generatePdf", 27 | ...defaultLambdaOptions, 28 | }); 29 | 30 | const checkTwitterLambda = new NodejsFunction(this, "checkTwitterLambda", { 31 | entry: "jobs/checkTwitter.js", 32 | handler: "checkTwitter", 33 | ...defaultLambdaOptions, 34 | }); 35 | 36 | // run jobs/checkTwitter.js every 2 minutes 37 | const rule = new events.Rule(this, "ScheduleRule", { 38 | schedule: events.Schedule.cron({ minute: "*/2" }), 39 | }); 40 | rule.addTarget(new targets.LambdaFunction(checkTwitterLambda)); 41 | 42 | // run jobs/generatePdfTopic.js whenever a message is published on the associated topic 43 | const generatePdfTopic = new sns.Topic(this, "generatePdfTopic"); 44 | generatePdfTopic.addSubscription( 45 | new subscriptions.LambdaSubscription(generatePdfLambda) 46 | ); 47 | 48 | // create the database 49 | // a vpc is always needed for databases 50 | const vpc = new ec2.Vpc(this, "example-vpc"); 51 | this.database = new rds.DatabaseInstance(this, "exampleDatabase", { 52 | engine: rds.DatabaseInstanceEngine.postgres({ 53 | version: rds.PostgresEngineVersion.VER_12_3, 54 | }), 55 | instanceIdentifier: "example", 56 | masterUsername: "example", 57 | databaseName: "example", 58 | vpc, 59 | instanceType: new ec2.InstanceType("t2.micro"), // see all types here: https://aws.amazon.com/rds/instance-types/ 60 | vpcPlacement: { 61 | subnetType: ec2.SubnetType.PUBLIC, 62 | }, 63 | deletionProtection: false, 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /infra/example-app.js: -------------------------------------------------------------------------------- 1 | import * as cdk from "@aws-cdk/core"; 2 | import ExampleStack from "./ExampleStack"; 3 | 4 | const app = new cdk.App(); 5 | const stack = new ExampleStack(app, "example-app", { 6 | description: "An example application", 7 | }); 8 | -------------------------------------------------------------------------------- /jobs/.babelrc.js: -------------------------------------------------------------------------------- 1 | // This file allows us to provide the babel configuration required by jobs/*.js for dev purposes 2 | // We cannot put it at the root of the project, otherwise it would be read as the Next.js babel 3 | // configuration. 4 | const path = require("path"); 5 | 6 | module.exports = { 7 | presets: ["@babel/preset-env"], 8 | sourceMaps: "inline", 9 | plugins: [ 10 | [ 11 | "module-resolver", 12 | { 13 | root: path.join(__dirname, ".."), 14 | }, 15 | ], 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /jobs/checkTwitter.js: -------------------------------------------------------------------------------- 1 | export async function checkTwitter() { 2 | const words = ["aws", "cdk"]; 3 | 4 | console.log( 5 | `Here we check twitter for the words ${words} and store it in our database` 6 | ); 7 | 8 | return true; 9 | } 10 | -------------------------------------------------------------------------------- /jobs/generatePdf.js: -------------------------------------------------------------------------------- 1 | export async function generatePdf(event) { 2 | const { words } = JSON.parse(event.Records[0].Sns.Message); 3 | 4 | console.log( 5 | `Here we check twitter for the words ${words}, generate a PDF and send it via email` 6 | ); 7 | 8 | return true; 9 | } 10 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "." 4 | }, 5 | "exclude": ["node_modules", ".next", "cdk.out", "postgres-data"] 6 | } 7 | -------------------------------------------------------------------------------- /lib/execJob.js: -------------------------------------------------------------------------------- 1 | import { execFile } from "child_process"; 2 | import path from "path"; 3 | import fs from "fs"; 4 | 5 | import tmp from "tmp"; 6 | 7 | export default function execJob(jobName, params) { 8 | const script = ` 9 | const {${jobName}} = require("${path.join( 10 | process.cwd(), 11 | "jobs", 12 | jobName 13 | )}.js"); 14 | async function run() { 15 | try { 16 | await ${jobName}(${JSON.stringify(params)}); 17 | } catch (error) { 18 | // if necessary, here you could do some cleanup like destroying pending database connections 19 | throw error; 20 | } 21 | } 22 | 23 | run();`; 24 | 25 | const tmpFile = tmp.fileSync(); 26 | fs.writeSync(tmpFile.fd, script); 27 | 28 | const babelNodeBin = path.join(process.cwd(), "node_modules/.bin/babel-node"); 29 | 30 | const child = execFile( 31 | babelNodeBin, 32 | [ 33 | tmpFile.name, 34 | "--config-file", 35 | path.join(process.cwd(), "jobs/.babelrc.js"), 36 | ], 37 | { 38 | env: { 39 | ...process.env, 40 | NODE_OPTIONS: "--enable-source-maps --unhandled-rejections=strict", 41 | }, 42 | }, 43 | function (err) { 44 | if (err) { 45 | console.error(`Error while running jobs/${jobName}.js`); 46 | } else { 47 | console.log(`Success running jobs/${jobName}.js`); 48 | } 49 | 50 | tmpFile.removeCallback(); 51 | } 52 | ); 53 | 54 | child.stdout.on("data", (data) => { 55 | return console.log(data); 56 | }); 57 | 58 | child.stderr.on("data", (data) => { 59 | return console.error(data); 60 | }); 61 | 62 | return child; 63 | } 64 | -------------------------------------------------------------------------------- /lib/privateConf.js: -------------------------------------------------------------------------------- 1 | // All those env variables should be set 2 | // Note: if you're on Vercel, you cannot add `AWS_ACCESS_KEY_ID`, 3 | // see https://vercel.com/docs/platform/limits#reserved-variables 4 | 5 | export const awsAccessKeyId = process.env.SECRET_AWS_ACCESS_KEY_ID; 6 | export const awsSecretAccessKey = process.env.SECRET_AWS_SECRET_ACCESS_KEY; 7 | export const awsRegion = process.env.SECRET_AWS_REGION; 8 | 9 | export const domain = process.env.DOMAIN || process.env.VERCEL_URL; 10 | export const protocol = process.env.PROTOCOL; 11 | 12 | export const appEnv = process.env.APP_ENV; 13 | -------------------------------------------------------------------------------- /lib/publishMessage.js: -------------------------------------------------------------------------------- 1 | // this file allows us to either publish messages to AWS (production) or to our own 2 | // SNS local implementation 3 | import { SNS } from "@aws-sdk/client-sns"; 4 | 5 | import { 6 | appEnv, 7 | awsAccessKeyId, 8 | awsSecretAccessKey, 9 | awsRegion, 10 | domain, 11 | protocol, 12 | } from "lib/privateConf"; 13 | 14 | export default async function publishMessage(topicName, messageObject) { 15 | console.log( 16 | `Sending message: ${JSON.stringify(messageObject)} to topic: ${topicName}` 17 | ); 18 | 19 | if (appEnv === "production") { 20 | const snsClient = new SNS({ 21 | credentials: { 22 | accessKeyId: awsAccessKeyId, 23 | secretAccessKey: awsSecretAccessKey, 24 | }, 25 | region: awsRegion, 26 | apiVersion: "2010-03-31", 27 | }); 28 | 29 | const { Topics: topics } = await snsClient.listTopics({}); 30 | // {} is a bug from AWS, see https://github.com/aws/aws-sdk-js-v3/issues/424 31 | const topic = topics.find((topic) => { 32 | return topic.TopicArn.includes(`:example-app-${topicName}`); 33 | }); 34 | 35 | await snsClient.publish({ 36 | TopicArn: topic.TopicArn, 37 | Message: JSON.stringify(messageObject), 38 | }); 39 | } else { 40 | // dev mode, we still do a request, as if we were on AWS 41 | await fetch(`${protocol}://${domain}/api/__devPublishMessage`, { 42 | method: "POST", 43 | body: JSON.stringify({ topic: topicName, message: messageObject }), 44 | headers: { "content-type": "application/json" }, 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-vercel-aws-cdk-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@aws-cdk/aws-ec2": "1.59.0", 12 | "@aws-cdk/aws-events": "1.59.0", 13 | "@aws-cdk/aws-events-targets": "1.59.0", 14 | "@aws-cdk/aws-rds": "1.59.0", 15 | "@aws-sdk/client-sns": "1.0.0-gamma.6", 16 | "cron": "^1.8.2", 17 | "next": "9.5.4", 18 | "react": "16.13.1", 19 | "react-dom": "16.13.1" 20 | }, 21 | "devDependencies": { 22 | "@aws-cdk/aws-lambda": "1.59.0", 23 | "@aws-cdk/aws-sns": "1.59.0", 24 | "@aws-cdk/aws-sns-subscriptions": "1.59.0", 25 | "@aws-cdk/core": "1.59.0", 26 | "@babel/node": "7.10.5", 27 | "@babel/preset-env": "7.11.0", 28 | "aws-cdk": "1.59.0", 29 | "aws-lambda-nodejs-webpack": "1.2.5", 30 | "babel-plugin-module-resolver": "4.0.0", 31 | "tmp": "0.2.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pages/api/__devPublishMessage.js: -------------------------------------------------------------------------------- 1 | // This file is the local equivalent of sending messages to SNS on AWS. 2 | // It's an API route that will spawn files in jobs/ using babel-node 3 | import { appEnv } from "lib/privateConf"; 4 | import execJob from "lib/execJob"; 5 | 6 | export default function __devPublishMessage(req, res) { 7 | if (appEnv === "production") { 8 | res.status(404).end(); 9 | return; 10 | } 11 | 12 | res.json({ ok: true }); 13 | 14 | // remember: executing code after res.json() only works in dev, not Vercel/AWS 15 | 16 | const { topic: jobName, message: messageObject } = req.body; 17 | execJob(jobName, { 18 | Records: [{ Sns: { Message: JSON.stringify(messageObject) } }], 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /pages/api/publishMessage.js: -------------------------------------------------------------------------------- 1 | // this file allows you to publish messages to any AWS SNS topic 2 | 3 | import publishMessage from "lib/publishMessage"; 4 | 5 | export default async function publishMessageRoute(req, res) { 6 | // You would have to protect this route just like any other API route 7 | // so that no one can publish messages that are not tied to the data they own 8 | 9 | await publishMessage(req.body.topic, req.body.message); 10 | res.send({ ok: true }); 11 | } 12 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | 3 | export default function Home() { 4 | return ( 5 | <> 6 | 7 | Create Next App 8 | 9 | 10 | 11 |

Next.js + Vercel + AWS example

12 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vvo/nextjs-vercel-aws-cdk-example/6755236a1e5f3f0a8e1827740ff19aa4252d8521/public/favicon.ico --------------------------------------------------------------------------------