Deploy Full-Stack Next.js T3App with Cognito and Prisma using AWS Lambda

simiobs

simi-obs

Posted on April 15, 2024

Deploy Full-Stack Next.js T3App with Cognito and Prisma using AWS Lambda

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:

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
Enter fullscreen mode Exit fullscreen mode

T3 App create - prompt answers

T3 App create - prompt answers

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: ./
Enter fullscreen mode Exit fullscreen mode

Contents of 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')
Enter fullscreen mode Exit fullscreen mode

Contents of 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'))
Enter fullscreen mode Exit fullscreen mode

Contents of stacktape.yml

  • You might notice that we have set generateClientSecret to true. Client secret is not generated by default but is required when using CognitoProvider with NextAuth.js.

  • We have also set allowed callbackURLs and allowed logoutURLs, 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 our web 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'))
Enter fullscreen mode Exit fullscreen mode

Contents of 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 and NEXTAUTH_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'))
Enter fullscreen mode Exit fullscreen mode

Contents of 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.

Secrets page

Secrets page

Create DB password secret

Create DB password secret

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

Create NextAuth.js secret

Create NextAuth.js secret

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
});
Enter fullscreen mode Exit fullscreen mode

Contents of 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);
Enter fullscreen mode Exit fullscreen mode

Contents of 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Contents of 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
Enter fullscreen mode Exit fullscreen mode

Contents of 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"
};
Enter fullscreen mode Exit fullscreen mode

Contents of 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Contents of 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
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
simiobs
simi-obs

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