├── sst-env.d.ts ├── .eslintrc.json ├── src ├── migrations │ ├── 0004_greedy_speed_demon.sql │ ├── 0002_light_celestials.sql │ ├── 0003_silly_surge.sql │ ├── 0001_large_mathemanic.sql │ ├── 0000_eager_silver_sable.sql │ └── meta │ │ ├── _journal.json │ │ ├── 0000_snapshot.json │ │ ├── 0002_snapshot.json │ │ ├── 0001_snapshot.json │ │ ├── 0003_snapshot.json │ │ └── 0004_snapshot.json └── app │ ├── favicon.ico │ ├── page.js │ ├── layout.js │ ├── lib │ ├── schema.js │ ├── secrets.js │ └── db.js │ ├── api │ ├── route.js │ └── leads │ │ └── route.js │ ├── leadCapture │ ├── hero.js │ └── forms.js │ ├── globals.css │ └── cli │ └── migrator.js ├── jsconfig.json ├── leads-landing.code-workspace ├── next.config.js ├── postcss.config.js ├── public ├── launching.jpg ├── vercel.svg └── next.svg ├── drizzle.config.js ├── README.md ├── .gitignore ├── tailwind.config.js ├── sst.config.ts ├── package.json └── .github └── workflows ├── main.yaml ├── production.yaml └── stage.yaml /sst-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /src/migrations/0004_greedy_speed_demon.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "leads" ADD COLUMN "description" text; -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /leads-landing.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/deploy-nextjs-neon-sst-aws-tutorial/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/launching.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/deploy-nextjs-neon-sst-aws-tutorial/HEAD/public/launching.jpg -------------------------------------------------------------------------------- /drizzle.config.js: -------------------------------------------------------------------------------- 1 | const drizzleConfig = { 2 | schema: './src/app/lib/schema.js', 3 | out: './src/migrations' 4 | } 5 | 6 | export default drizzleConfig; -------------------------------------------------------------------------------- /src/app/page.js: -------------------------------------------------------------------------------- 1 | 2 | import HeroLandingPage from '@/app/leadCapture/hero' 3 | 4 | 5 | export default function Home() { 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /src/migrations/0002_light_celestials.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "leads" DROP COLUMN IF EXISTS "first_name";--> statement-breakpoint 2 | ALTER TABLE "leads" DROP COLUMN IF EXISTS "last_name"; -------------------------------------------------------------------------------- /src/migrations/0003_silly_surge.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "leads" ADD COLUMN "first_name" varchar(150);--> statement-breakpoint 2 | ALTER TABLE "leads" ADD COLUMN "last_name" varchar(150); -------------------------------------------------------------------------------- /src/migrations/0001_large_mathemanic.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "leads" ADD COLUMN "first_name" varchar(150);--> statement-breakpoint 2 | ALTER TABLE "leads" ADD COLUMN "last_name" varchar(150); -------------------------------------------------------------------------------- /src/migrations/0000_eager_silver_sable.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "leads" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "email" text NOT NULL, 4 | "created_at" timestamp DEFAULT now() 5 | ); 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deploy Next.js with Neon, SST, & AWS 2 | 3 | In this tutorial series, you'll learn how to deploy a Next.js project that is made to collect emails from leads and store them in a Neon Postgres database. The prjoect will be deployed on various AWS services, such as Lambda and Cloudfront, using the tool SST. 4 | 5 | 6 | ## Setup -------------------------------------------------------------------------------- /src/app/layout.js: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | import { Inter } from 'next/font/google' 3 | 4 | const inter = Inter({ subsets: ['latin'] }) 5 | 6 | export const metadata = { 7 | title: 'Sales Landing Page with Next, AWS & Neon', 8 | description: 'Sales Landing Page with Next + Neon', 9 | } 10 | 11 | export default function RootLayout({ children }) { 12 | return ( 13 | 14 | {children} 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .open-next/ 3 | .sst 4 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 7 | ], 8 | theme: { 9 | extend: { 10 | backgroundImage: { 11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 12 | 'gradient-conic': 13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sst.config.ts: -------------------------------------------------------------------------------- 1 | import { SSTConfig } from "sst"; 2 | import { Config, NextjsSite } from "sst/constructs"; 3 | 4 | export default { 5 | config(_input) { 6 | return { 7 | name: "leads-landing", 8 | region: "us-east-1", 9 | }; 10 | }, 11 | stacks(app) { 12 | app.stack(function Site({ stack }) { 13 | const DATABASE_URL = new Config.Secret(stack, "DATABASE_URL") 14 | const SECRET_VAL = new Config.Secret(stack, "SECRET_VAL") 15 | const site = new NextjsSite(stack, "site", { 16 | bind: [DATABASE_URL, SECRET_VAL], 17 | }); 18 | 19 | 20 | stack.addOutputs({ 21 | SiteUrl: site.url, 22 | }); 23 | }); 24 | }, 25 | } satisfies SSTConfig; 26 | -------------------------------------------------------------------------------- /src/app/lib/schema.js: -------------------------------------------------------------------------------- 1 | import validator from 'validator' 2 | import {serial, pgTable, text, timestamp, varchar} from 'drizzle-orm/pg-core' 3 | import { createInsertSchema } from 'drizzle-zod' 4 | 5 | export const LeadTable = pgTable("leads", { 6 | id: serial("id").primaryKey().notNull(), 7 | email: text("email").notNull(), 8 | firstName: varchar("first_name", {length: 150}), 9 | lastName: varchar("last_name", {length: 150}), 10 | description: text("description"), 11 | createdAt: timestamp("created_at").defaultNow(), 12 | }) 13 | 14 | export const insertLeadTableSchema = createInsertSchema(LeadTable, { 15 | email: (schema) => schema.email.email(), 16 | firstName: (schema) => schema.firstName.min(2).max(150).optional(), 17 | lastName: (schema) => schema.lastName.min(2).max(150).optional(), 18 | }) -------------------------------------------------------------------------------- /src/app/lib/secrets.js: -------------------------------------------------------------------------------- 1 | import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm'; 2 | 3 | const STAGE = process.env.STAGE ? process.env.STAGE : "production" 4 | const PROJECT = "leads-landing" 5 | const REGION = "us-east-1" 6 | 7 | export default async function getSecret (secretName) { 8 | if (!secretName) { 9 | return null 10 | } 11 | const client = new SSMClient({region: REGION}) 12 | const paramName = `/sst/${PROJECT}/${STAGE}/Secret/${secretName}/value` 13 | const paramOptions = { 14 | Name: paramName, 15 | WithDecryption: true 16 | } 17 | const command = new GetParameterCommand(paramOptions) 18 | try { 19 | const response = await client.send(command) 20 | return response.Parameter.Value 21 | } catch (error) { 22 | return null 23 | } 24 | } -------------------------------------------------------------------------------- /src/app/api/route.js: -------------------------------------------------------------------------------- 1 | import {NextResponse} from 'next/server' 2 | import {Config} from 'sst/node/config' 3 | 4 | import {dbNow, addLead} from '@/app/lib/db' 5 | 6 | export const dynamic = 'force-dynamic' 7 | export const runtime = 'nodejs' 8 | export const fetchCache = 'force-no-store' 9 | export const revalidate = 0 10 | 11 | export async function GET(request){ 12 | const secretVal = Config.SECRET_VAL 13 | const dbString = Config.DATABASE_URL 14 | const stage = Config.STAGE 15 | const dbResult = await dbNow() 16 | const leadResult = await addLead({email: "abc123@abc123.com"}) 17 | const now = dbResult ? dbResult[0].now : null 18 | return NextResponse.json({ 19 | hello: "World", 20 | stage: stage, 21 | secretVal: secretVal, 22 | leadResult: leadResult, 23 | dbString: `${dbString}`.slice(0, 25), 24 | now: now 25 | }, {status: 200}) 26 | 27 | } -------------------------------------------------------------------------------- /src/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1697151853305, 9 | "tag": "0000_eager_silver_sable", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "5", 15 | "when": 1697151983893, 16 | "tag": "0001_large_mathemanic", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "5", 22 | "when": 1697152015907, 23 | "tag": "0002_light_celestials", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "5", 29 | "when": 1697152025585, 30 | "tag": "0003_silly_surge", 31 | "breakpoints": true 32 | }, 33 | { 34 | "idx": 4, 35 | "version": "5", 36 | "when": 1697215146820, 37 | "tag": "0004_greedy_speed_demon", 38 | "breakpoints": true 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /src/app/lib/db.js: -------------------------------------------------------------------------------- 1 | import { neon, neonConfig } from '@neondatabase/serverless'; 2 | import { drizzle } from 'drizzle-orm/neon-http'; 3 | import { Config } from 'sst/node/config' 4 | 5 | import * as schema from '@/app/lib/schema' 6 | 7 | export async function dbClient (useSQLOnly) { 8 | neonConfig.fetchConnectionCache = true; 9 | const sql = neon(Config.DATABASE_URL) 10 | if (useSQLOnly) { 11 | return sql 12 | } 13 | return drizzle(sql); 14 | } 15 | 16 | export async function dbNow () { 17 | const sql = await dbClient(true) 18 | const dbResult = await sql`SELECT NOW()` 19 | if (dbResult.length === 1) { 20 | return dbResult[0].now 21 | } 22 | return null 23 | } 24 | 25 | export async function addLead({email}){ 26 | const db = await dbClient(false) 27 | const dbResult = await db.insert(schema.LeadTable).values({email: email}).returning({timestamp: schema.LeadTable.createdAt}) 28 | if (dbResult.length === 1) { 29 | return dbResult[0].timestamp 30 | } 31 | return 32 | } -------------------------------------------------------------------------------- /src/app/leadCapture/hero.js: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import LeadCaptureForm from '@/app/leadCapture/forms' 3 | export default function HeroLandingPage () { 4 | 5 | return
6 |
7 |
8 |

Landing Page for Sales Leads!

9 |

Using Next.js to deploy to AWS with Neon

10 | 11 |
12 |
13 | Landing Page Rocket 14 |
15 |
16 |
17 | } -------------------------------------------------------------------------------- /src/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "id": "49b183d1-80b3-43ec-9af7-84da8b213d1d", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "leads": { 8 | "name": "leads", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "email": { 18 | "name": "email", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "created_at": { 24 | "name": "created_at", 25 | "type": "timestamp", 26 | "primaryKey": false, 27 | "notNull": false, 28 | "default": "now()" 29 | } 30 | }, 31 | "indexes": {}, 32 | "foreignKeys": {}, 33 | "compositePrimaryKeys": {}, 34 | "uniqueConstraints": {} 35 | } 36 | }, 37 | "enums": {}, 38 | "schemas": {}, 39 | "_meta": { 40 | "schemas": {}, 41 | "tables": {}, 42 | "columns": {} 43 | } 44 | } -------------------------------------------------------------------------------- /src/migrations/meta/0002_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "id": "4c7bd463-f831-45f3-89fd-bf8c203ad26f", 5 | "prevId": "6cf51274-700e-4ec6-ba20-dbcbd458c7eb", 6 | "tables": { 7 | "leads": { 8 | "name": "leads", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "email": { 18 | "name": "email", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "created_at": { 24 | "name": "created_at", 25 | "type": "timestamp", 26 | "primaryKey": false, 27 | "notNull": false, 28 | "default": "now()" 29 | } 30 | }, 31 | "indexes": {}, 32 | "foreignKeys": {}, 33 | "compositePrimaryKeys": {}, 34 | "uniqueConstraints": {} 35 | } 36 | }, 37 | "enums": {}, 38 | "schemas": {}, 39 | "_meta": { 40 | "schemas": {}, 41 | "tables": {}, 42 | "columns": {} 43 | } 44 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "leads-landing", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "sst bind next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "generate": "drizzle-kit generate:pg --config=drizzle.config.js", 11 | "migrate": "tsx src/app/cli/migrator.js", 12 | "deployStaging": "sst deploy --stage staging", 13 | "deploy": "sst deploy --stage production" 14 | }, 15 | "dependencies": { 16 | "@neondatabase/serverless": "^0.6.0", 17 | "drizzle-orm": "^0.28.6", 18 | "drizzle-zod": "^0.5.1", 19 | "next": "13.5.4", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "validator": "^13.11.0", 23 | "zod": "^3.22.4", 24 | "zod-validation-error": "^1.5.0" 25 | }, 26 | "devDependencies": { 27 | "autoprefixer": "^10.4.16", 28 | "aws-cdk-lib": "2.95.1", 29 | "aws-sdk": "^2.1473.0", 30 | "constructs": "10.2.69", 31 | "drizzle-kit": "^0.19.13", 32 | "eslint": "^8.51.0", 33 | "eslint-config-next": "13.5.4", 34 | "postcss": "^8.4.31", 35 | "sst": "^2.29.0", 36 | "tailwindcss": "^3.3.3", 37 | "tsx": "^3.13.0" 38 | } 39 | } -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | /* body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } */ 28 | 29 | @layer base { 30 | input[type='email'] { 31 | @apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500; 32 | } 33 | 34 | .btn-join { 35 | @apply text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: One-Off Deploy via SST to AWS 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: '18.x' 14 | - name: Cache Next.js Build 15 | uses: actions/cache@v3 16 | with: 17 | path: | 18 | .next/cache/ 19 | .open-next/ 20 | .sst/ 21 | key: cache-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**.[jt]s', '**.[jt]xs') }} 22 | restore-keys: | 23 | cache-${{ hashFiles('**/pnpm-lock.yaml') }}- 24 | - name: Install Pnpm 25 | run: npm install -g pnpm 26 | 27 | - name: Install projects deps 28 | run: pnpm install 29 | 30 | - name: Install AWS Creds 31 | run: | 32 | mkdir -p ~/.aws 33 | echo "[default]" > ~/.aws/credentials 34 | echo "aws_access_key_id=${{ secrets.AWS_ACCESS_KEY_ID }}" >> ~/.aws/credentials 35 | echo "aws_secret_access_key=${{ secrets.AWS_SECRET_ACCESS_KEY }}" >> ~/.aws/credentials 36 | 37 | - name: Set SST Config Secret 38 | run: | 39 | npx sst secrets set COMMIT_SHA '${{ github.sha }}' --stage production 40 | 41 | - name: Deploy to AWS with SST 42 | run: pnpm run deploy 43 | 44 | - name: Clean Up AWS Profile 45 | run: | 46 | rm -rf ~/.aws -------------------------------------------------------------------------------- /src/migrations/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "id": "6cf51274-700e-4ec6-ba20-dbcbd458c7eb", 5 | "prevId": "49b183d1-80b3-43ec-9af7-84da8b213d1d", 6 | "tables": { 7 | "leads": { 8 | "name": "leads", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "email": { 18 | "name": "email", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "first_name": { 24 | "name": "first_name", 25 | "type": "varchar(150)", 26 | "primaryKey": false, 27 | "notNull": false 28 | }, 29 | "last_name": { 30 | "name": "last_name", 31 | "type": "varchar(150)", 32 | "primaryKey": false, 33 | "notNull": false 34 | }, 35 | "created_at": { 36 | "name": "created_at", 37 | "type": "timestamp", 38 | "primaryKey": false, 39 | "notNull": false, 40 | "default": "now()" 41 | } 42 | }, 43 | "indexes": {}, 44 | "foreignKeys": {}, 45 | "compositePrimaryKeys": {}, 46 | "uniqueConstraints": {} 47 | } 48 | }, 49 | "enums": {}, 50 | "schemas": {}, 51 | "_meta": { 52 | "schemas": {}, 53 | "tables": {}, 54 | "columns": {} 55 | } 56 | } -------------------------------------------------------------------------------- /src/migrations/meta/0003_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "id": "3929ed22-e2af-40e9-8cb7-ceefe94aea34", 5 | "prevId": "4c7bd463-f831-45f3-89fd-bf8c203ad26f", 6 | "tables": { 7 | "leads": { 8 | "name": "leads", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "email": { 18 | "name": "email", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "first_name": { 24 | "name": "first_name", 25 | "type": "varchar(150)", 26 | "primaryKey": false, 27 | "notNull": false 28 | }, 29 | "last_name": { 30 | "name": "last_name", 31 | "type": "varchar(150)", 32 | "primaryKey": false, 33 | "notNull": false 34 | }, 35 | "created_at": { 36 | "name": "created_at", 37 | "type": "timestamp", 38 | "primaryKey": false, 39 | "notNull": false, 40 | "default": "now()" 41 | } 42 | }, 43 | "indexes": {}, 44 | "foreignKeys": {}, 45 | "compositePrimaryKeys": {}, 46 | "uniqueConstraints": {} 47 | } 48 | }, 49 | "enums": {}, 50 | "schemas": {}, 51 | "_meta": { 52 | "schemas": {}, 53 | "tables": {}, 54 | "columns": {} 55 | } 56 | } -------------------------------------------------------------------------------- /src/app/cli/migrator.js: -------------------------------------------------------------------------------- 1 | 2 | import { migrate } from "drizzle-orm/postgres-js/migrator"; 3 | import { drizzle } from 'drizzle-orm/neon-serverless'; 4 | import { Pool, neonConfig } from '@neondatabase/serverless'; 5 | 6 | import * as schema from "../lib/schema"; 7 | import getSecret from "../lib/secrets"; 8 | 9 | 10 | import ws from 'ws'; 11 | 12 | 13 | 14 | async function performMigration() { 15 | const dbUrl = await getSecret("DATABASE_URL") 16 | if (!dbUrl) { 17 | return 18 | } 19 | // connect to Neon via websocket 20 | 21 | neonConfig.webSocketConstructor = ws; // <-- this is the key bit 22 | 23 | const pool = new Pool({ connectionString: dbUrl }); 24 | pool.on('error', err => console.error(err)); // deal with e.g. re-connect 25 | 26 | const client = await pool.connect(); 27 | 28 | try { 29 | await client.query('BEGIN'); 30 | const db = await drizzle(client, {schema}) 31 | await migrate(db, { migrationsFolder: "src/migrations" }); 32 | await client.query('COMMIT'); 33 | 34 | } catch (err) { 35 | await client.query('ROLLBACK'); 36 | throw err; 37 | 38 | } finally { 39 | client.release(); 40 | } 41 | 42 | await pool.end(); 43 | 44 | } 45 | 46 | if (require.main === module) { 47 | console.log("Running migrations") 48 | 49 | performMigration().then((val)=>{ 50 | console.log("Migration performed") 51 | process.exit(0) 52 | }).catch((err)=>{ 53 | console.log("err", err) 54 | process.exit(1) 55 | }) 56 | } -------------------------------------------------------------------------------- /src/app/leadCapture/forms.js: -------------------------------------------------------------------------------- 1 | "use client" 2 | import {useState, useRef} from 'react' 3 | 4 | export default function LeadCaptureForm () { 5 | const [loading, setLoading] = useState(false) 6 | const [message, setMessage] = useState('') 7 | const formRef = useRef(null) 8 | 9 | const handleForm = async (event) => { 10 | event.preventDefault() 11 | setLoading(true) 12 | const formData = new FormData(event.target) 13 | 14 | const dataObject = Object.fromEntries(formData) 15 | 16 | const jsonData = JSON.stringify(dataObject) 17 | 18 | const options = { 19 | method: "POST", // HTTP POST 20 | headers: { 21 | "Content-Type": "application/json" 22 | }, 23 | body: jsonData 24 | } 25 | 26 | // fetch 27 | const response = await fetch("/api/leads/", options) 28 | // const responseData = await response.json() 29 | if (response.ok) { 30 | setMessage("Thank you for joining") 31 | formRef.current.reset() 32 | } else { 33 | setMessage("Error with your request") 34 | } 35 | setLoading(false) 36 | 37 | } 38 | const btnLabel = loading ? "Loading" : "Join List" 39 | return <> 40 | {message &&
{message}
} 41 |
42 | 43 | 44 |
45 | 46 | } -------------------------------------------------------------------------------- /src/app/api/leads/route.js: -------------------------------------------------------------------------------- 1 | import {NextResponse} from 'next/server' 2 | // import validator from 'validator' 3 | import {z as zod} from 'zod' 4 | import {fromZodError} from 'zod-validation-error' 5 | 6 | import * as db from '@/app/lib/db' 7 | import * as schema from '@/app/lib/schema' 8 | 9 | export const dynamic = 'force-dynamic' 10 | export const runtime = 'nodejs' 11 | export const fetchCache = 'force-no-store' 12 | export const revalidate = 0 13 | 14 | export async function POST(request){ // HTTP POST 15 | const contentType = await request.headers.get("content-type") 16 | if (contentType !== "application/json") { 17 | return NextResponse.json({message: "Invalid request"}, {status: 415}) 18 | } 19 | const data = await request.json() 20 | let parsedData = {} 21 | try { 22 | parsedData = await schema.insertLeadTableSchema.parse(data) 23 | } catch (error) { 24 | if (error instanceof zod.ZodError) { 25 | const validationError = fromZodError(error) 26 | return NextResponse.json({errorList: validationError}, {status: 400}) 27 | } 28 | return NextResponse.json({message: "Some Server Error"}, {status: 500}) 29 | } 30 | const {email} = parsedData 31 | if (!email) { 32 | return NextResponse.json({message: "A valid email is required"}, {status: 400}) 33 | } 34 | const dbNow = await db.dbNow() 35 | const leadResult = await db.addLead({email: email}) 36 | 37 | const resultData = { 38 | leadResult: leadResult, 39 | dbNow: dbNow 40 | } 41 | return NextResponse.json(resultData, {status: 201}) 42 | 43 | } -------------------------------------------------------------------------------- /src/migrations/meta/0004_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "id": "016f2609-1992-4c2a-b7ed-72597b1fc44b", 5 | "prevId": "3929ed22-e2af-40e9-8cb7-ceefe94aea34", 6 | "tables": { 7 | "leads": { 8 | "name": "leads", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "email": { 18 | "name": "email", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "first_name": { 24 | "name": "first_name", 25 | "type": "varchar(150)", 26 | "primaryKey": false, 27 | "notNull": false 28 | }, 29 | "last_name": { 30 | "name": "last_name", 31 | "type": "varchar(150)", 32 | "primaryKey": false, 33 | "notNull": false 34 | }, 35 | "description": { 36 | "name": "description", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": false 40 | }, 41 | "created_at": { 42 | "name": "created_at", 43 | "type": "timestamp", 44 | "primaryKey": false, 45 | "notNull": false, 46 | "default": "now()" 47 | } 48 | }, 49 | "indexes": {}, 50 | "foreignKeys": {}, 51 | "compositePrimaryKeys": {}, 52 | "uniqueConstraints": {} 53 | } 54 | }, 55 | "enums": {}, 56 | "schemas": {}, 57 | "_meta": { 58 | "schemas": {}, 59 | "tables": {}, 60 | "columns": {} 61 | } 62 | } -------------------------------------------------------------------------------- /.github/workflows/production.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy Production via SST to AWS 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - production 7 | types: 8 | - closed 9 | # workflow_dispatch: 10 | 11 | jobs: 12 | production: 13 | if: github.event.pull_request.merged == true 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: '18.x' 20 | - name: Cache Next.js Build 21 | uses: actions/cache@v3 22 | with: 23 | path: | 24 | .next/cache/ 25 | .open-next/ 26 | .sst/ 27 | key: cache-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**.[jt]s', '**.[jt]xs') }} 28 | restore-keys: | 29 | cache-${{ hashFiles('**/pnpm-lock.yaml') }}- 30 | - name: Install Pnpm 31 | run: npm install -g pnpm 32 | 33 | - name: Install projects deps 34 | run: pnpm install 35 | 36 | - name: Install AWS Creds 37 | run: | 38 | mkdir -p ~/.aws 39 | echo "[default]" > ~/.aws/credentials 40 | echo "aws_access_key_id=${{ secrets.AWS_ACCESS_KEY_ID }}" >> ~/.aws/credentials 41 | echo "aws_secret_access_key=${{ secrets.AWS_SECRET_ACCESS_KEY }}" >> ~/.aws/credentials 42 | 43 | - name: Set SST Config Secret 44 | run: | 45 | npx sst secrets set COMMIT_SHA '${{ github.sha }}' --stage production 46 | 47 | - name: Production Database Migrations 48 | run: | 49 | pnpm run migrate 50 | 51 | - name: Deploy to AWS with SST 52 | run: pnpm run deploy 53 | 54 | - name: Clean Up AWS Profile 55 | run: | 56 | rm -rf ~/.aws 57 | 58 | # - name: Remove Staging Deployment 59 | # run: | 60 | # npx sst remove --stage staging -------------------------------------------------------------------------------- /.github/workflows/stage.yaml: -------------------------------------------------------------------------------- 1 | name: Staging Deploy via SST to AWS 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - "main" 8 | - "master" 9 | paths: 10 | - "src/**" 11 | - "*.json" 12 | - "*.yaml" 13 | - "*.json" 14 | - "*.config.js" 15 | - "*.config.ts" 16 | 17 | jobs: 18 | deploy: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: actions/setup-node@v3 23 | with: 24 | node-version: '18.x' 25 | - name: Cache Next.js Build 26 | uses: actions/cache@v3 27 | with: 28 | path: | 29 | .next/cache/ 30 | .open-next/ 31 | .sst/ 32 | key: cache-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**.[jt]s', '**.[jt]xs') }} 33 | restore-keys: | 34 | cache-${{ hashFiles('**/pnpm-lock.yaml') }}- 35 | - name: Install Pnpm 36 | run: npm install -g pnpm 37 | 38 | - name: Install projects deps 39 | run: pnpm install 40 | 41 | - name: Install AWS Creds 42 | run: | 43 | mkdir -p ~/.aws 44 | echo "[default]" > ~/.aws/credentials 45 | echo "aws_access_key_id=${{ secrets.AWS_ACCESS_KEY_ID }}" >> ~/.aws/credentials 46 | echo "aws_secret_access_key=${{ secrets.AWS_SECRET_ACCESS_KEY }}" >> ~/.aws/credentials 47 | 48 | - name: Set SST Config Secret 49 | run: | 50 | npx sst secrets set DATABASE_URL '${{ secrets.DATABASE_URL_STAGING }}' --stage staging 51 | npx sst secrets set SECRET_VAL '${{ github.sha }}' --stage staging 52 | npx sst secrets set COMMIT_SHA '${{ github.sha }}' --stage staging 53 | 54 | - name: Run Staging DB migrations 55 | run: | 56 | STAGE=staging npx tsx src/app/cli/migrator.js 57 | 58 | - name: Deploy to AWS with SST 59 | run: pnpm run deployStaging 60 | 61 | - name: Clean Up AWS Profile 62 | run: | 63 | rm -rf ~/.aws 64 | 65 | - name: Create Pull Request to Production 66 | if: success() 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | run: 70 | gh pr create --base production --head main --title "Auto- ${{ github.event.head_commit.message }}" --body "Auto PR from Stage Release" --------------------------------------------------------------------------------