Deploy Full-Stack Next.js T3App with Cognito and Prisma using AWS Lambda
simi-obs
Posted on April 15, 2024
Author note
This tutorial was originally published as a part of Stacktape documentation. I advise to follow this tutorial directly in the Stacktape docs due to better readability and code highlighting.
Introduction
Deploying a full-stack Next.js web app can seem complex, but with the right tools, it's straightforward. This tutorial will cover the essentials to get your app up and running quickly:
- Initializing Next.js project using CT3A: How to initialize a full-stack, typesafe Next.js app using T3 App scaffolder.
- Deploying Next.js app on AWS Lambda: How to use Stacktape to deploy your web app using a serverless approach.
- Setting up a Postgres database with Prisma ORM: Steps to integrate a robust database system with your app using Prisma ORM
- Implementing Authentication with Cognito: Simplify user authentication by integrating AWS Cognito and NextAuth.js into your app.
This guide is designed to be practical and to the point, ensuring you can deploy your app efficiently with Stacktape.
Prerequisites
To complete this tutorial, you will need:
- NodeJS (18+) installed on your system
- Stacktape account and AWS account connected to your organization
- (OPTIONAL) If you are using VSCode, we recommend using our Stacktape extension for easier writing of Stacktape template.
Initializing App Using CT3A
Initialize your app using T3 App scaffolder.
To scaffold an app using create-t3-app
, run the following commands and answer the command prompt questions:
npm create t3-app@latest
Create stacktape.yml
In your project directory root, create stacktape.yml
file.
Every Stacktape project starts with a simple template file. This is your app's single source of truth, the blueprint of your app. Unlike other setups where you might juggle multiple template files for different tools, Stacktape brings everything into one place. You can write your template in YAML, TypeScript, or even Python.
In this tutorial, we will be using YAML.
To write the Stacktape template more efficiently, consider using our VSCode extension or the stack composer editor in the console.
Add nextjs-web resource
Add the resources section with nextjs-web resource called web
to your stacktape.yml
file.
resources:
web:
type: nextjs-web
properties:
appDirectory: ./
stacktape.yml
The nextjs-web resource type is a purpose-built resource for deploying Next.js app using AWS Lambda. In the background, it packages your Next.js app and creates multiple different AWS resources required to run your website as efficiently as possible. To learn more about the resource, refer to our docs.
Add relational-database resource
In this step, we add a relational-database resource to our template. Since during the CT3A initialization, we have chosen to use PostgreSQL
. Therefore, the database should also use the postgres
engine.
resources:
web:
type: nextjs-web
properties:
appDirectory: ./
database:
type: relational-database
properties:
engine:
type: postgres
properties:
primaryInstance:
instanceSize: db.t4g.micro
credentials:
masterUserPassword: $Secret('t3app-db-password')
stacktape.yml
You might notice that the password for our database references a secret using a $Secret directive. We will create the secret later in the tutorial.
Add user-auth-pool resource
We use Cognito user-auth-pool to provide an authentication mechanism for our Next.js app. By using this resource, we offload user management and authentication responsibilities from developers to a managed service. Luckily, Cognito user-auth-pool easily integrates with NextAuth.js, which we have chosen as our authentication provider during the CT3A initialization.
resources:
web:
type: nextjs-web
properties:
appDirectory: ./
database:
type: relational-database
properties:
engine:
type: postgres
properties:
primaryInstance:
instanceSize: db.t4g.micro
credentials:
masterUserPassword: $Secret('t3app-db-password')
userPool:
type: user-auth-pool
properties:
generateClientSecret: true
callbackURLs:
- $CfFormat('{}/api/auth/callback/cognito', $ResourceParam('web', 'url'))
logoutURLs:
- $CfFormat('{}/api/auth/signout', $ResourceParam('web', 'url'))
stacktape.yml
You might notice that we have set
generateClientSecret
totrue
. Client secret is not generated by default but is required when using CognitoProvider with NextAuth.js.We have also set allowed
callbackURLs
and allowedlogoutURLs
, which need to be configured for sign-in/sign-out to work.
Since we do not know what the URL of our Next.js web will be yet (Stacktape will assign URL during deployment):
- we are using $CfFormat directive to "compose" the final URLs during the deployment.
- Inside the $CfFormat directive, we are using $ResourceParam directive to get
url
parameter of ourweb
resource. This directive will also be resolved during the deployment.
Add environment variables
We have added relational-database
and user-auth-pool
resources, but we still need to pass information about them into our nextjs-web
resource. We will do this by using environment variables.
resources:
web:
type: nextjs-web
properties:
appDirectory: ./
environment:
- name: DATABASE_URL
value: $ResourceParam('database', 'connectionString')
- name: NEXTAUTH_SECRET
value: $Secret('t3app-nextauth-secret')
- name: NEXTAUTH_URL
value: $ResourceParam('web', 'url')
- name: USER_POOL_CLIENT_ID
value: $ResourceParam('userPool', 'clientId')
- name: USER_POOL_CLIENT_SECRET
value: $ResourceParam('userPool', 'clientSecret')
- name: USER_POOL_PROVIDER_URL
value: $ResourceParam('userPool', 'providerUrl')
- name: USER_POOL_DOMAIN
value: $ResourceParam('userPool', 'domain')
database:
type: relational-database
properties:
engine:
type: postgres
properties:
primaryInstance:
instanceSize: db.t4g.micro
credentials:
masterUserPassword: $Secret('t3app-db-password')
userPool:
type: user-auth-pool
properties:
generateClientSecret: true
callbackURLs:
- $CfFormat('{}/api/auth/callback/cognito', $ResourceParam('web', 'url'))
logoutURLs:
- $CfFormat('{}/api/auth/signout', $ResourceParam('web', 'url'))
stacktape.yml
As we can see, we have added a lot of environment variables, so let me break it down for you:
-
DATABASE_URL
- required for communicating with the database. We are using $ResourceParam directive to resolve the connection string URL during deployment.NEXTAUTH_SECRET
andNEXTAUTH_URL
- are required for the correct functioning of NextAuth.js. You can see that we have used a $Secret directive to reference a secret. We will create the secret later in the tutorial. -
USER_POOL_CLIENT_ID
+USER_POOL_CLIENT_SECRET
+USER_POOL_PROVIDER_URL
+USER_POOL_DOMAIN
- these environment variables are resolved during deployment from our userPool resource and are required for making CognitoProvider work.
In many cases, it would be easier to use connectTo property to automatically inject information about other resources into our Next.js web app. In this tutorial, we are adhering to how CT3A names the env variables and we are passing the environment variables explicitly.
Add scripts and hooks
We have all the resources configured but are still missing a few pieces. During CT3A initialization, we chose to use Prisma ORM to make it easy to work and interact with the Postgres database in our stack.
The pre-generated schema (data model definitions) resides in prisma/schema.prisma
file. To ensure that both our Prisma client (which we use in our Next.js web) and our Postgres database schema are in sync with our Prisma schema, we need to:
-
Before deployment: generate Prisma client using the command
prisma generate
. The generated client will be used inside the Next.js web. -
After deployment: migrate Postgres database schema using the command
prisma db push
, so the Postgres database is in sync with our Prisma schema data model.
To automate these steps and make them part of our deployment process, we will use scripts and hooks
scripts:
generateClient:
type: local-script
properties:
executeCommand: npx prisma generate
migrateSchema:
type: local-script
properties:
executeCommand: npx prisma db push
environment:
- name: DATABASE_URL
value: $ResourceParam('database', 'connectionString')
hooks:
beforeDeploy:
- scriptName: generateClient
afterDeploy:
- scriptName: migrateSchema
resources:
web:
type: nextjs-web
properties:
appDirectory: ./
environment:
- name: DATABASE_URL
value: $ResourceParam('database', 'connectionString')
- name: NEXTAUTH_SECRET
value: $Secret('t3app-nextauth-secret')
- name: NEXTAUTH_URL
value: $ResourceParam('web', 'url')
- name: USER_POOL_CLIENT_ID
value: $ResourceParam('userPool', 'clientId')
- name: USER_POOL_CLIENT_SECRET
value: $ResourceParam('userPool', 'clientSecret')
- name: USER_POOL_PROVIDER_URL
value: $ResourceParam('userPool', 'providerUrl')
- name: USER_POOL_DOMAIN
value: $ResourceParam('userPool', 'domain')
database:
type: relational-database
properties:
engine:
type: postgres
properties:
primaryInstance:
instanceSize: db.t4g.micro
credentials:
masterUserPassword: $Secret('t3app-db-password')
userPool:
type: user-auth-pool
properties:
generateClientSecret: true
callbackURLs:
- $CfFormat('{}/api/auth/callback/cognito', $ResourceParam('web', 'url'))
logoutURLs:
- $CfFormat('{}/api/auth/signout', $ResourceParam('web', 'url'))
stacktape.yml
In the scripts section, we have specified two scripts:
-
generateClient
- generates client -
migrateSchema
- migrates schema (env variable DATABASE_URL is automatically resolved during script execution).
In the hooks section, we reference the scripts to execute during specified deployment phases. This is the way of telling Stacktape when to execute the scripts. You can also execute scripts manually using script:run command.
Create Secrets
In our stacktape.yml
we have referenced two secrets t3app-db-password
and t3app-nextauth-secret
.
You can create these secrets easily in stacktape console.
Remember to create secrets in a region where you plan to deploy.
From NextAuth.js docs: You can quickly create a good value for the NEXTAUTH_SECRET on the command line via this openssl command:
openssl rand -base64 32
Adjust CT3A Next.js app
So far, we have:
- initialized our T3 Next.js app
- created our
stacktape.yml
, - and created secrets.
Last step is to make minor adjustments to the generated Next.js web app to make it work with AWS Lambda (upon which our nextjs-web resource is built) and AWS Cognito.
In the upcoming sections, we will always highlight parts of the code that were modified.
Modify src/env.js
Apps initialized by CT3A use the @t3-oss/env-nextjs
package to bring order, validation, and transparency into environment variables handling.
Based on the environment variables we are passing to our nextjs-web resource, we need to adjust the src/env.js
:
- We are commenting out env variables needed for Discord auth (CT3A app initializes a project to use DiscordProvider with NextAuth.js, but we will be using CognitoProvider) and replacing them with variables needed for Cognito auth.
- We are disabling/skipping the validation of environment variables during the build because environment variables will be resolved during deployment but are not available during build time.
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
DATABASE_URL: z
.string()
.url()
.refine((str) => !str.includes("YOUR_MYSQL_URL_HERE"), "You forgot to change the default URL"),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
NEXTAUTH_SECRET: process.env.NODE_ENV === "production" ? z.string() : z.string().optional(),
NEXTAUTH_URL: z.preprocess(
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
// Since NextAuth.js automatically uses the VERCEL_URL if present.
(str) => process.env.VERCEL_URL ?? str,
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
process.env.VERCEL ? z.string() : z.string().url()
),
// adding user pool env variables
USER_POOL_CLIENT_ID: z.string(),
USER_POOL_CLIENT_SECRET: z.string(),
USER_POOL_PROVIDER_URL: z.string(),
USER_POOL_DOMAIN: z.string()
// DISCORD_CLIENT_ID: z.string(),
// DISCORD_CLIENT_SECRET: z.string(),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
// user pool env variables
USER_POOL_CLIENT_ID: process.env.USER_POOL_CLIENT_ID,
USER_POOL_CLIENT_SECRET: process.env.USER_POOL_CLIENT_SECRET,
USER_POOL_PROVIDER_URL: process.env.USER_POOL_PROVIDER_URL,
USER_POOL_DOMAIN: process.env.USER_POOL_DOMAIN
// DISCORD_CLIENT_ID : process.env.DISCORD_CLIENT_ID,
// DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: true // !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true
});
src/env.js
Modify src/server/auth.ts
As we have touched on earlier, the CT3A app initializes a project to use DiscordProvider with NextAuth.js, but we are using CognitoProvider. Therefore, we need to modify src/server/auth.ts
.
import { PrismaAdapter } from "@auth/prisma-adapter";
import { getServerSession, type DefaultSession, type NextAuthOptions } from "next-auth";
import { type Adapter } from "next-auth/adapters";
import CognitoProvider from "next-auth/providers/cognito";
// import DiscordProvider from "next-auth/providers/discord";
import { env } from "~/env";
import { db } from "~/server/db";
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
}
// interface User {
// // ...other properties
// // role: UserRole;
// }
}
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
* @see https://next-auth.js.org/configuration/options
*/
export const authOptions: NextAuthOptions = {
callbacks: {
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id
}
})
},
adapter: PrismaAdapter(db) as Adapter,
providers: [
CognitoProvider({
clientId: env.USER_POOL_CLIENT_ID,
clientSecret: env.USER_POOL_CLIENT_SECRET,
issuer: env.USER_POOL_PROVIDER_URL
})
// DiscordProvider({
// clientId: env.DISCORD_CLIENT_ID,
// clientSecret: env.DISCORD_CLIENT_SECRET,
// }),
/**
* ...add more providers here.
*
* Most other providers require a bit more work than the Discord provider. For example, the
* GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
* model. Refer to the NextAuth.js docs for the provider you want to use. Example:
*
* @see https://next-auth.js.org/providers/github
*/
]
};
/**
* Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file.
*
* @see https://next-auth.js.org/configuration/nextjs
*/
export const getServerAuthSession = () => getServerSession(authOptions);
src/server/auth.ts
Modify src/trpc/react.tsx
During CT3A initialization, we chose to use tRPC framework. This framework simplifies and accelerates the development of typesafe APIs in TypeScript applications by automatically inferring types from your API to the client.
However, CT3A initialized the tRPC client to use new HTTP streaming features, which are still unstable. Moreover, streaming is still considered experimental with both OpenNEXT adapter (internal part of nextjs-web resource) and AWS Lambda (on which the Next.js app will run).
Therefore, we switch from the HTTP streaming to the proven HTTP request/response.
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { loggerLink, httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState } from "react";
import { type AppRouter } from "~/server/api/root";
import { getUrl, transformer } from "./shared";
const createQueryClient = () => new QueryClient();
let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client
return createQueryClient();
}
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= createQueryClient());
};
export const api = createTRPCReact<AppRouter>();
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
api.createClient({
transformer,
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" || (op.direction === "down" && op.result instanceof Error)
}),
httpBatchLink({
url: getUrl()
})
]
})
);
return (
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
);
}
src/trpc/react.tsx
Modify prisma/schema.prisma
By default, the prisma generate
command generates a Prisma client that is runnable on your current OS platform (platform of your workstation). However, once we deploy, the Prisma client will be running on AWS Lambda which might (and probably does) use a different underlying OS platform.
To make Prisma generate a client for AWS Lambda, we need to specify binaryTargets
for client in prisma/schema.prisma
file:
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "rhel-openssl-1.0.x"]
}
datasource db {
provider = "postgresql"
// NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below
// Further reading:
// https://next-auth.js.org/adapters/prisma#create-the-prisma-schema
// https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User @relation(fields: [createdById], references: [id])
createdById String
@@index([name])
}
// ... rest of the schema
prima/schema.prisma
(incomplete)
Create src/middleware.ts
NextAuth.js is not perfect. One of the shortcomings is that it currently does not implement federated logout. This means that even if a user signs out of the Next.js app, he does NOT get signed out of the Cognito user pool client. As a consequence, the user is not really being logged out (i.e he is able to login again without providing the credentials). You can read more about this problem in this Github thread.
The simple solution for this problem is to create a redirect to facilitate the logout. We will implement the redirect using Next.js middleware. Create a src/middleware.ts
file with the following content:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { env } from "~/env";
// This function can be marked `async` if using `await` inside
export const middleware = (_request: NextRequest) => {
return NextResponse.redirect(
[
`https://${env.USER_POOL_DOMAIN}/logout?client_id=${env.USER_POOL_CLIENT_ID}`,
`logout_uri=${encodeURIComponent(`${env.NEXTAUTH_URL}/api/auth/signout`)}`,
`redirect_uri=${encodeURIComponent(`${env.NEXTAUTH_URL}/api/auth/signout`)}`,
`response_type=code`
].join("&")
);
};
export const config = {
matcher: "/api/signout"
};
src/middleware.ts
Modify app/page.tsx
Now that we have added the redirect, we need to make use of it inside our Next.js app. Modify the signout link inside the app/page.tsx
file:
import { unstable_noStore as noStore } from "next/cache";
import Link from "next/link";
import { CreatePost } from "~/app/_components/create-post";
import { getServerAuthSession } from "~/server/auth";
import { api } from "~/trpc/server";
export default async function Home() {
noStore();
const hello = await api.post.hello.query({ text: "from tRPC" });
const session = await getServerAuthSession();
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16 ">
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
Create <span className="text-[hsl(280,100%,70%)]">T3</span> App
</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-8">
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 hover:bg-white/20"
href="https://create.t3.gg/en/usage/first-steps"
target="_blank"
>
<h3 className="text-2xl font-bold">First Steps →</h3>
<div className="text-lg">
Just the basics - Everything you need to know to set up your database and authentication.
</div>
</Link>
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 hover:bg-white/20"
href="https://create.t3.gg/en/introduction"
target="_blank"
>
<h3 className="text-2xl font-bold">Documentation →</h3>
<div className="text-lg">Learn more about Create T3 App, the libraries it uses, and how to deploy it.</div>
</Link>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-2xl text-white">{hello ? hello.greeting : "Loading tRPC query..."}</p>
<div className="flex flex-col items-center justify-center gap-4">
<p className="text-center text-2xl text-white">
{session && <span>Logged in as {session.user?.email}</span>}
</p>
<Link
href={session ? "/api/signout" : "/api/auth/signin"}
className="rounded-full bg-white/10 px-10 py-3 font-semibold no-underline transition hover:bg-white/20"
>
{session ? "Sign out" : "Sign in"}
</Link>
</div>
</div>
<CrudShowcase />
</div>
</main>
);
}
async function CrudShowcase() {
const session = await getServerAuthSession();
if (!session?.user) return null;
const latestPost = await api.post.getLatest.query();
return (
<div className="w-full max-w-xs">
{latestPost ? (
<p className="truncate">Your most recent post: {latestPost.name}</p>
) : (
<p>You have no posts yet.</p>
)}
<CreatePost />
</div>
);
}
app/page.tsx
Deploy
Now, we are ready to deploy. You have two options for how to deploy:
- deploy using git (push-to-deploy)
- deploy using CLI
Deploy using Git (push-to-deploy)
If you prefer to deploy your app using push-to-deploy
Github/Gitlab integration, create a repository and then connect your repository to the Stacktape in the console. After that, push your app into the configured repository and branch.
To use Git for deploy, you can also follow our detailed tutorial.
Deploy using CLI
If you prefer to deploy from your workstation, you can use Stacktape CLI. To install it, follow the instructions in our docs.
After you have installed the CLI, use the codebuild:deploy or deploy command inside your project directory to deploy your app:
stacktape deploy --region eu-west-1 --stage my-stage --project-name my-t3-app
Explore the app
Explore your application in the Stacktape console. You can find your app URL and other information there.
If you used CLI to deploy URL of your app along with the link to the console will be printed into terminal.
Posted on April 15, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024