Implementation of Hanko Authentication on a Next.JS Application with the incorporation of Stripe, Replicate and Prisma ORM

Implementation of Hanko Authentication on a Next.JS Application with the incorporation of Stripe, Replicate and Prisma ORM

Hanko is a platform that enables you to log in using passkeys thus eliminating the need to use passwords and 2FA and enhancing the authentication of the users while making it easy to sign in and remove the need to always memorize passwords or write them down on a notebook.

Passkeys are now the future of authentication with huge organizations such as Apple, Google and Microsoft leading the front in promoting the use of the passkeys as a way to replace the day-in-day-out passwords. In this article, we will be looking at Hanko's implementation of passkeys.

Goal

In this article, we will build a Next.js application that generates images with the help of Replicate with the inclusion of various aspects propelling the project including Hanko for Authentication, Stripe for Payments and Prisma to maintain the database.

Prerequisites

To follow along, you will need:

Introduction

Passkeys are a new form of authentication that is set to revamp the existing passwords and 2-Factor-Authentication (2FA) and even replace them as the main form of authentication.

Passkeys are stored in the system using the Public Key Cryptography where the passkey is a private key stored in the user’s device created using biometric data while the public key is stored on the server. Passkeys, here, are only created for the services that they are created for.

And since they are stored using Public Key Cryptography, it is difficult for others to gain access to your account since one can’t log in using only the public key cryptography.

Public Key Cryptography

Cryptography refers to the encoding and decoding of messages to maintain confidentiality, integrity, and authentication of information in transit. Public Key Cryptography is also known as asymmetric cryptography. In this method, there is a public key and a private key. The public key is known widely whereas the private key is the secret to a communicating pair.

Why should you use passkeys

  • With passkeys, you can easily log in to your account with the use of the device’s screen lock such as the fingerprint sensor, facial recognition or entering a PIN, same ease that you use when unlocking your phone.

  • You can easily use the same passkey across various devices as long as you have been registered on a website using passkeys.

  • Prevents the need to memorize different passwords across your differently registered sites.

  • Through passkeys, you are protected from phishing attacks since passkeys are only present in those sites that have registered their use and they cannot expose your passkey.

How to create a new one

Passkey user journey - biometric data is just used to unlock the public key cryptography. A key pair is created with the private key stored as a passkey to the password manager and the public key to the server. The passkey has metadata: username and server domain for which websites know which passkey to ask for. We will learn further how to create one when we set up Hanko authentication in the system.

Setting up the development application

The application will use the following dependencies:

  • Hanko: You'll need Hanko for the authentication of the users.

  • Jose: You’ll need Jose to encrypt and decrypt your tokens and the JSON Web Tokens.

  • Stripe: You'll need Stripe to process payments within the system.

  • Replicate: You'll need Replicate to act as the endpoint for the LLM.

  • Prisma: You'll need Prisma for ORM for migration and database management.

  • Toast: You can use Toast to present notifications to the front end.

Creating the app

Next.js is a React framework that is used to build web applications using JavaScript. It is termed a Full-Stack Language since you can be able to perform both front-end and back-end functions on it.

For us to get started, we first need to create a directory from which we will be working and then run the following command under the bash in Visual Studio Code:


npx create-next-app@latest

This will therefore install the Next.js app into your directory. You can then apply the following settings:

We can also set up the .env file to look like this and then populate the lines as we go on with the project:

# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: <https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema>

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: <https://pris.ly/d/connection-strings>

DATABASE_URL=<YOUR_DATABASE_URL>
NEXT_PUBLIC_HANKO_API_URL=<YOUR_HANKO_API_URL>
REPLICATE_API_TOKEN=<YOUR_REPLICATE_API_TOKEN>
STRIPE_SECRET_KEY=<YOUR_STRIPE_SECRET_KEY>
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=<YOUR_STRIPE_PUBLISHABLE_KEY>
STRIPE_WEBHOOK_ENDPOINT_SECRET=<YOUR_STRIPE_WEBHOOK_ENDPOINT_SECRET>
HOST_NAME=http://localhost:3000/
PRICE_ID_50=<YOUR_STRIPE_PRICE_ID>
PRICE_ID_100=<YOUR_STRIPE_PRICE_ID>
PRICE_ID_250=<YOUR_STRIPE_PRICE_ID>

The key fields with ‘NEXT_PUBLIC’ in them indicate that those values will be visible to the code files that use “use client”.

Running the code

After setting these up, you can run the code by using the following command in the bash:

npm run dev

The application will be running as long as one does not terminate the command.

Setting up Prisma

We will use Prisma as our toolkit for querying data from our DB.

To get started with Prisma, run this in your bash:

npm i prisma --save-dev

After that, we need to initialize Prisma using the following command:

npx prisma init

This will introduce a prisma directory which will contain the schema.prisma file that will act as the database schema and will create a .env file and add a connection to the database which you can now change and link to your database of choice. In this project, we will be linking it with Supabase.

Setting up Supabase

To use Supabase, you need to have an account there, if you don’t, you can [sign up](https://supabase.com/dashboard/sign-up) in this.

Once you are logged in, you can start your project in the following ways:

  • You first need to create a new organization.

  • You will then need to fill in these fields to create your organization.

  • Then you can be able to create your project.

Once you have created your project, you can be able to find your Connection URL, under Project Settings and then Database, which will be used to link your Supabase database to the schema models in the schema.prisma. The Connection URL will be used as the Database URL in the .env file and in place of your “[YOUR-PASSWORD]”, use the Database Password that you inputted or generated at the creation of the project.

Creating a database schema

You can be able to edit or add items to your database using the schema.prisma file.

For our project, we need various models: Account, User, Session, Audio and Verification Token

// This is your Prisma schema file,
// learn more about it in the docs: <https://pris.ly/d/prisma-schema>

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Account {
  id                 String  @id @default(cuid())
  userId             String
  type               String
  provider           String
  providerAccountId  String
  refresh_token      String?  @db.Text
  access_token       String?  @db.Text
  expires_at         Int?
  token_type         String?
  scope              String?
  id_token           String?  @db.Text
  session_state      String?

  user               User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  clientId     String
  writerId     String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  accounts      Account[]
  sessions      Session[]
  audios        Audio[]
  credits       Int       @default(5)
}

model Audio {
  id         String @id @default(cuid())
  prompt  String?
  version   String?
  User      User? @relation(fields: [userId], references: [id])
  userId    String?
  createdAt DateTime @default(now())
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

model Payment {
  id      String @id @default(cuid())
  orderId String
  status  String
}

Push the database schema

Once we have finished creating the database schema, we can push it to the database by running this command:

npx prisma db push

Installing Prisma Client

We will need the Prisma Client so that we can be able to query the database. To install it, we need to run this:

npm i @prisma/client

then we can generate the types for the Prisma Client

npx prisma generate

Prisma and Next.js

We can then be able to introduce Prisma into our project by creating a directory called server and a file under it called prisma.ts which will host the implementation of the Prisma Client.

import { PrismaClient } from "@prisma/client";

let prisma: PrismaClient;

if (process.env.NODE_ENV === "production") {
    prisma = new PrismaClient();
} else {
    if (!global.prisma) {
        global.prisma = new PrismaClient();
    }
    prisma = global.prisma;
}
export default prisma;

Migrating the database to Supabase

To migrate the Prisma models to Supabase, you need to run the following command:

npx prisma migrate dev

Where you will be prompted to name the migration then the system will finalize the migration.

Below is the Postgres Migration of the models:

-- CreateTable
CREATE TABLE "Account" (
    "id" TEXT NOT NULL,
    "userId" TEXT NOT NULL,
    "type" TEXT NOT NULL,
    "provider" TEXT NOT NULL,
    "providerAccountId" TEXT NOT NULL,
    "refresh_token" TEXT,
    "access_token" TEXT,
    "expires_at" INTEGER,
    "token_type" TEXT,
    "scope" TEXT,
    "id_token" TEXT,
    "session_state" TEXT,

    CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "Session" (
    "id" TEXT NOT NULL,
    "sessionToken" TEXT NOT NULL,
    "userId" TEXT NOT NULL,
    "clientId" TEXT NOT NULL,
    "writerId" TEXT NOT NULL,
    "expires" TIMESTAMP(3) NOT NULL,

    CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "User" (
    "id" TEXT NOT NULL,
    "name" TEXT,
    "email" TEXT,
    "emailVerified" TIMESTAMP(3),
    "image" TEXT,
        "credits" INTEGER NOT NULL DEFAULT 5

    CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "Audio" (
    "id" TEXT NOT NULL,
    "prompt" TEXT,
    "version" TEXT,
    "userId" TEXT,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT "Audio_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "VerificationToken" (
    "identifier" TEXT NOT NULL,
    "token" TEXT NOT NULL,
    "expires" TIMESTAMP(3) NOT NULL
);

-- CreateTable
CREATE TABLE "Payment" (
    "id" TEXT NOT NULL,
    "orderId" TEXT NOT NULL,
    "status" TEXT NOT NULL,

    CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");

-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");

-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");

-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");

-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Audio" ADD CONSTRAINT "Audio_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

Setting up Hanko Authentication

To use Hanko with Next.js you first need to install its package by running this command:

npm install @teamhanko/hanko-elements

You will need the Hanko API URL which you can get from the Hanko console which requires you to have a Hanko account.

If you do not have one, you can sign up for one by just typing out your email and the system will review if your email exists or not. Since you will be creating a new account, the system will guide you into creating a new account with the use of passkeys.

You can create passkeys by using either pins or a security key that will store the data or a fingerprint if your laptop/computer accepts it or having the alternative of using your phone to scan a QR code and then proceed with signing up on your phone with the use of biometric data of either finger.

Once you are logged in, you can then create your project, the dashboard will provide a view of key performance indicators (KPIs) and other important information that will enable progress http://localhost:3000 or your production link onto the project to get the API URL.

You can then copy the API URL from your Hanko Console and use it in your .env file.

NEXT_PUBLIC_HANKO_API_URL=https://e5******-d3**-4**2-b**9-2e69*******f.hanko.io

Adding the Login Interface

We use the <hanko-auth> component to add the login component into the system. In this, we will use the API URL that we had gotten from Hanko Console. We can then implement this code after creating a components directory on the root of the application folder. We will call this file HankoAuth.tsx. In this, we will be using React hooks to view the state and perform any changes onto the system when one is logging and when one can log in, a callback will be effected to move the user from the ‘/login’ page to the ‘/dashboard’ page and in case an error happens to occur, the system should indicate the error experienced.

The // @ts-ignore indicates to TypeScript to ignore any errors on the subsequent line.

"use client";

import { useEffect, useCallback, useState } from "react";
import { useRouter } from "next/navigation";
// @ts-ignore
import { register, Hanko } from "@teamhanko/hanko-elements";

const hankoAPI = process.env.NEXT_PUBLIC_HANKO_API_URL;

export default function HankoAuth() {
  const router = useRouter();

  const [hanko, setHanko] = useState<Hanko>();
  useEffect(() => {
    import("@teamhanko/hanko-frontend-sdk").then(({ Hanko }) =>
      setHanko(new Hanko(hankoAPI))
    );
  }, []);

  const redirectAfterLogin = useCallback(() => {
    router.replace("/dashboard");
  }, [router]);

  useEffect(
    () =>
      hanko?.onAuthFlowCompleted(() => {
        redirectAfterLogin();
      }),
    [hanko, redirectAfterLogin]
  );

  useEffect(() => {
    register(hankoAPI).catch((error) => {
      console.log(error);
    });
  }, []);

  return <hanko-auth />;
}

After this, we will use this component to create a Login page under the app directory under src. We will create a Login folder and place a page.tsx file which will host the Login page functionality.

"use client";

import dynamic from "next/dynamic";
const HankoAuth = dynamic(() => import("../../../components/HankoAuth"), {
  ssr: false,
});
import Header from "../../../components/Header";
import Footer from "../../../components/Footer";
import styles from "../page.module.css";

export default function LoginPage() {
  return (
    <>
      <div className={styles.divmain}>
        <Header />
        <main className={styles.main}>
          <HankoAuth />
        </main>
        <Footer />
      </div>
    </>
  );
}

Thus we will be able to have this:

Creation of the Dashboard

Hanko also provides a dashboard interface using the <hanko-profile> functionality where you can be able to view the emails and passkeys that you used to log into your application. We can do this by creating a HankoProfile.tsx file under the components directory and running this code.

"use client";

import { useEffect } from "react";
import { register } from "@teamhanko/hanko-elements";

const hankoAPI = process.env.NEXT_PUBLIC_HANKO_API_URL;

export default function HankoProfile() {
  useEffect(() => {
    register(hankoAPI).catch((error) => {
      console.log(error);
    });
  }, []);

  return (
    <>
      <hanko-profile />
    </>
  );
}

We can then customize the dashboard and create one under the app directory by creating a folder called Dashboard and then pages.tsx file.

"use client";

import Header from "../../../components/Header";
import Footer from "../../../components/Footer";
import dynamic from "next/dynamic";
const HankoProfile = dynamic(() => import("../../../components/HankoProfile"), {
  ssr: false,
});

import styles from "../page.module.css";

const DashboardPage = () => {
  return (
    <div className={styles.divmain}>
      <Header />
      <main className={styles.main}>
        <HankoProfile />
      </main>
      <Footer />
    </div>
  );
};

export default DashboardPage;

Where we can be able to acquire this:

Logout Implementation

We can also create a logout functionality for logging out users by using a logout button that Hanko has created. We can implement this under the LogoutButton.tsx under the components directory. We will also use React hooks where they will check the state of the system first before processing the logout function. Once the user clicks on logout, the logout function will be invoked and direct the user to the ‘/login’ page if the logout process has been successful or show an error if otherwise.

"use client";

import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import styles from "../src/app/page.module.css";
// @ts-ignore
import type { Hanko } from "@teamhanko/hanko-elements";

const hankoAPI = process.env.NEXT_PUBLIC_HANKO_API_URL;

export function LogoutBtn() {
  const router = useRouter();
  const [hanko, setHanko] = useState<Hanko>();

  useEffect(() => {
    // @ts-ignore
    import("@teamhanko/hanko-elements").then(({ Hanko }) =>
      setHanko(new Hanko(hankoAPI ?? ""))
    );
  }, []);

  const logout = async () => {
    try {
      await hanko?.user.logout();
      router.push("/login");
      router.refresh();
      return;
    } catch (error) {
      console.error("Error during logout:", error);
    }
  };

  return (
    <button onClick={logout} className={styles.logoutbtn}>
      Logout
    </button>
  );
}

Using the Middleware for securing routes

We can be able to add a JWT (JSON Web Token) as a verification middleware where we will be implementing the Jose library. This will then ensure that there are secure routes offered to the termed routes in the file. We can use it in extracting and verifying the JWT from cookies and redirecting unauthorized users to the login page.

To work with Jose, we first need to run this under the bash:

npm install jose

We can then generate this code under the middleware.ts file which we can create on the root of the application. The JWKS (JSON Web Key Set) will be used to check the JWT got from the Hanko cookies. This middleware runs as a protection for the ‘/dashboard’ page in this case where when one invokes requests for the ‘/dashboard’ page the verification of the JWT is done and if successful, the user will be able to access the ‘/dashboard’ page else if an error occurs, the user is directed to the ‘/login’ page.

import { NextResponse, NextRequest } from "next/server";

import { jwtVerify, createRemoteJWKSet } from "jose";

const hankoApiUrl = process.env.NEXT_PUBLIC_HANKO_API_URL;

export async function middleware(req: NextRequest) {
  const hanko = req.cookies.get("hanko")?.value;

  const JWKS = createRemoteJWKSet(
    new URL(`${hankoApiUrl}/.well-known/jwks.json`)
  );

  try {
    const verifiedJWT = await jwtVerify(hanko ?? "", JWKS);
    console.log(verifiedJWT);
  } catch {
    return NextResponse.redirect(new URL("/login", req.url));
  }
}

export const config = {
  matcher: ["/dashboard"],
};

Working with other components

Header

We can work with a Header file which will contain the name of the application and the nav bar for easier walkthrough within the application. Under the components directory, we will create the Header.tsx file and have the following code which will implement the use of sessions from Hanko to check if a user is logged in under the isLoggedIn function to filter out the items under the navbar:

"use client";

import React from "react";
import Link from "next/link";
import { LogoutBtn } from "./LogoutButton";
import styles from "../src/app/page.module.css";
import Image from "next/image";
import { useEffect, useState } from "react";
// @ts-ignore
import { Hanko } from "@teamhanko/hanko-elements";

const hankoAPI = process.env.NEXT_PUBLIC_HANKO_API_URL;

export default function Header() {
  const [hanko, setHanko] = useState<Hanko>();

  useEffect(() => {
    // @ts-ignore
    import("@teamhanko/hanko-elements").then(({ Hanko }) =>
      setHanko(new Hanko(hankoAPI ?? ""))
    );
  }, []);

  const sess = hanko?.session.isValid();
  const isLoggedIn = !!sess;

  return (
    <>
      <header className={styles.nav}>
        <div>
          {isLoggedIn && (
            <Link href="/dashboard" className={styles.navhome}>
              <Image
                alt="logo"
                src="/speaksync.png"
                width={150}
                height={30}
                className={styles.logoimg}
              />
            </Link>
          )}
          {!isLoggedIn && (
            <Link href="/" className={styles.navhome}>
              <Image
                alt="logo"
                src="/speaksync.png"
                width={150}
                height={30}
                className={styles.logoimg}
              />
            </Link>
          )}
          <nav className={styles.navmenu}>
            {isLoggedIn && (
              <Link href="/dashboard" className={styles.navlink}>
                Dashboard
              </Link>
            )}
            {isLoggedIn && (
              <Link href="/transcribe" className={styles.navlink}>
                Transcribe
              </Link>
            )}
            {isLoggedIn && (
              <Link href="/pricing" className={styles.navlink}>
                Pricing
              </Link>
            )}
            <div className={styles.navsubmenu}>
              {isLoggedIn && (
                <>
                  <Link href="/logout" passHref legacyBehavior>
                    <LogoutBtn />
                  </Link>
                </>
              )}
              {!isLoggedIn && (
                <Link href="/login" className={styles.navlink}>
                  Login
                </Link>
              )}
            </div>
          </nav>
        </div>
      </header>
    </>
  );
}

Setting up Stripe

We will be working with Stripe for payments. We first need to create a Stripe account through this. After setting up your account, we will head over to the Developer part to get the API keys where we can be able to find the various key tokens which we will use in our .env file. Below is an example of them:

Afterward, we will need to set up an endpoint onto which Stripe will look when receiving requests from the application:

Stripe Webhooks

We can create an API for Stripe by creating a payments folder in the api directory and create a route.ts file in it with the following code:

import Stripe from "stripe";
import { prisma } from "@/server/db";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: "2023-10-16",
});

const webhookSecret = process.env.STRIPE_WEBHOOK_ENDPOINT_SECRET;

type Metadata = {
  userId: string;
  credits: string;
};

export async function POST(req: any, res: Response) {
  const body = await req.text();
  const signature = req.headers["stripe-signature"];
  let event;

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
    console.log("Event", event);
  } catch (err) {
    return new Response(
      `Webhook Error: ${err instanceof Error ? err.message : "Unknown Error"}`,
      { status: 400 }
    );
  }

  switch (event.type) {
    case "checkout.session.completed":
      const completedEvent = event.data.object as Stripe.Checkout.Session & {
        metadata: Metadata;
      };

      const userId = completedEvent.metadata.userId;
      const credits = completedEvent.metadata.credits;

      await prisma.user.update({
        where: {
          id: userId,
        },
        data: {
          credits: {
            increment: parseInt(credits),
          },
        },
      });

      break;
    default:
      console.log(`Unhandled event type ${event.type}`);
  }
  return new Response(null, { status: 200 });
}

The code above sends a POST request which sets up a webhook event where we get the body which will have data sent by Stripe to the event, and the Stripe signature and pass them together with your Stripe Webhook Secret which verifies the Stripe signature to the Stripe library onto the event. If the process fails, an error will be sent.

In this case, we are working with the “checkout.session.completed” event type, hence we get the completedEvent and link the userId and the credits that the user bought into the transaction and update them based on the bundle that was bought.

We can link the Stripe Secret Key into the project by introducing it and its version by creating a lib folder in the src directory and creating a stripe.ts file with the following code::

import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: "2023-10-16",
});

We will create an action.ts file under the pricingcomponents folder in the components directory which will perform the checkoutAction functionality in the system.

The system first checks if the user is logged in to be able to buy the credits, if not, an error will be displayed to indicate that one should log in first. We then bring in the price ids for the credit amounts that one can acquire, we will then get the price ids from the Stripe Product Catalog. If we find the price id successfully, we then invoke the stripe.checkout.sessions.create function which will indicate to Stripe what we are doing, attach the user metadata onto the transaction present what we are paying for and finally have a redirect based on whether the transaction was successful or not.

"use server";

import { stripe } from "../../src/lib/stripe";
import { userInfo } from "./sess";
//@ts-ignore
import { Hanko } from "@teamhanko/hanko-elements";

export async function checkoutAction(credits: number) {

  const session = await userInfo;

  if (!session) {
    throw new Error("You must be logged in to checkout");
  }
  const priceIds: Record<number, string> = {
    50: process.env.PRICE_ID_50!,
    120: process.env.PRICE_ID_120!,
    250: process.env.PRICE_ID_250!,
  };

  const priceId = priceIds[credits];

  if (!priceId) {
    throw new Error("Invalid price id");
  }

  return stripe.checkout.sessions.create({
    mode: "payment",
    payment_method_types: ["card"],
    metadata: {
      userId: Hanko?.session.res.userId,
      credits: credits,
    },
    line_items: [
      {
        price: priceId,
        quantity: 1,
      },
    ],
    success_url: `${process.env.HOST_NAME}/dashboard`,
    cancel_url: `${process.env.HOST_NAME}/pricing`,
  });
}

Obtaining Current User ID

We can be able to obtain the Current User ID from Hanko through the following command which will be in the sess.ts file which is under the pricingcomponents folder under the components directory:

export async function userInfo(req: Request, res: Response) {
  const response = await fetch(`${process.env.HOST_NAME}/me`, {
    method: "GET",
  })
    .then((response) => response.json())
    .then((response) => console.log(response))
    .catch((err) => console.error(err));
  console.log(response);
}

Creation of Products

For the credit products offered, we will need to create them under Stripe and also within the system. For the one within the system, we can create a credits.json file under the pricingcomponents folder and store all of the relevant information in it. The number of products here will dictate the number of products that the user will see.

[
  {
    "id": 1,
    "credits": 50,
    "description": "50 Credits",
    "image": "/bronze.png",
    "quantity": 1,
    "price": 5
  },

  {
    "id": 2,
    "credits": 100,
    "description": "100 Credits",
    "image": "/silver.png",
    "quantity": 1,
    "price": 9
  },
  {
    "id": 3,
    "credits": 250,
    "description": "250 Credits",
    "quantity": 1,
    "image": "/gold.png",
    "price": 20
  }
]

We can then create the products under the Product Catalog in Stripe where you can set the product details and pricing and then we can acquire the Price IDs for each product which we can place under the .env file.

Setting up the Pricing Card and Page

After the creation of the prices in both Stripe and the system, we can therefore be able to link the product information and show them to the users of the system using this code:

"use client";

import { checkoutAction } from "../../../components/pricingcomponents/actions";
import { loadStripe } from "@stripe/stripe-js";
import { ToastContainer, toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import styles from "../page.module.css";
import data from "../../../components/pricingcomponents/credits.json";

const stripePromise = loadStripe(
  process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
);

export default function PricingCard() {
  return (
    <>
      <div className={styles.divmain}>
        <main className={styles.main}>
          <h1>Buy your Credits</h1>
          <p className={styles.p}>
            Use the credits as tokens to transcribe new audios
          </p>
          <div>
            <div className={styles.cardlayout}>
              {data.map((credit) => (
                <div key={credit.id} className={styles.card}>
                  <p>{credit.credits} Credits</p>
                  <p>Acquire your {credit.description}</p>
                  <img src={credit.image} />
                  <button
                    onClick={() =>
                      checkoutAction(credit.credits)
                        .then(async (session) => {
                          const stripe = await stripePromise;
                          if (stripe === null) return;
                          await stripe.redirectToCheckout({
                            sessionId: session.id,
                          });
                        })
                        .catch(() => {
                          toast.error(
                            "Something went wrong. You must be logged in to buy credits",
                            {
                              position: toast.POSITION.BOTTOM_RIGHT,
                            }
                          );
                          <ToastContainer />;
                        })
                    }
                  >
                    Buy for ${credit.price}
                  </button>
                </div>
              ))}
            </div>
          </div>
        </main>
      </div>
    </>
  );
}

The Pricing Cards will display how much each credit bundle costs based on the JSON file and then link the checkoutAction functionality when you click on buy any bundle.

Following that, we can be able to link this to the pricing page under the app directory.

"use client";

import PricingCard from "./pricing-card";
import styles from "../page.module.css";
import Head from "next/head";
import Image from "next/image";
import Header from "../../../components/Header";
import Footer from "../../../components/Footer";

export default function Pricing() {
  return (
    <>
      <div className={styles.divmain}>
        <Head>
          <title>Imggen Pricing</title>
          <Image src="/imggen.png" alt="logo" />
        </Head>
        <Header />
        <main className={styles.main}>
          <PricingCard />
        </main>
        <Footer />
      </div>
    </>
  );
}

After creating this, your page will look like this:

And once you click to buy a bundle, you will be redirected to the Stripe checkout page that will look like this:

Once you fill in the details and click on pay, the credits will be topped over onto your account.

Setting up Replicate

We will use Replicate API to transcribe the audio that is provided by the user. You will first need to create your Replicate Account from which you will be able to obtain your API token under API-Tokens.

Accessing the Replicate API

Under the api directory, we can create a transcriptions folder which we will place this code under the route.ts file.

export async function POST(req: Request, res: Response) {
  const response = await fetch("https://api.replicate.com/v1/predictions", {
    method: "POST",
    headers: {
      Authorization: `Token ${process.env.REPLICATE_API_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      version:
        "249170b5f45bb1e0aa68440f1f28ef25f5ee50a882af365555068f1f61ae791b",

      input: {
        file_string: "...",
      },
    }),
  });

  if (response.status !== 201) {
    let error = await response.json();
    return new Response(JSON.stringify({ detail: error.detail }));
  }

  const transcription = await response.json();
  console.log("Transcription response:", transcription);
  return new Response(JSON.stringify({ transcription }));
}

Transcribing Audios

We can create a folder called transcribe and place a page.tsx file in which users can be able to upload their audio file and have it transcribed.

"use client";

import React from "react";
import styles from "../page.module.css";
import Head from "next/head";
import { useState } from "react";
import Header from "../../../components/Header";
import Footer from "../../../components/Footer";

const sleep = (ms: any) => new Promise((r) => setTimeout(r, ms));

export default function Transcribe() {
  const [transcription, setTranscription] = useState(null);
  const [error, setError] = useState(null);
  const handleSubmit = async (e) => {
    e.preventDefault();
    const response = await fetch("/api/transcriptions", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        prompt: e.target.prompt.value,
      }),
    });
    let transcription = await response.json();
    if (response.status !== 201) {
      setError(transcription.detail);
      return;
    }
    setTranscription(transcription);

    while (
      transcription.status !== "succeeded" &&
      transcription.status !== "failed"
    ) {
      await sleep(1000);
      const response = await fetch("/api/transcriptions/" + transcription.id);
      transcription = await response.json();
      if (response.status !== 200) {
        setError(transcription.detail);
        return;
      }
      console.log({ transcription });
      console.log("transcription.output:", transcription.output);
      setTranscription(transcription);
    }
  };
  return (
    <>
      <div className={styles.divmain}>
        <Head>
          <title>Transcribe Audios</title>
        </Head>
        <Header />
        <main className={styles.main}>
          <h1>Transcribe your audios</h1>
          <p className={styles.p1}>
            Just upload your audio and we will do the rest.
          </p>
          <form className={styles.form} onSubmit={handleSubmit}>
            <div className={styles.p1}>
              Choose your audio{" "}
              <span className={styles.formspan}>Max: 25MB</span>
            </div>
            <input
              className={styles.forminput}
              type="file"
              name="prompt"
              onSubmit={handleSubmit}
              accept="audio"
              max={25 * 1024 * 1024}
            />
            <div className={styles.formdiv}>
              <button type="submit">Submit</button>
            </div>
          </form>
          {error && <div>{error}</div>}
          {transcription && (
            <div>
              {transcription.output && (
                <div className={styles.imageWrapper}>
                  <p className={transcription} />
                </div>
              )}
              <p>status: {transcription.status}</p>
            </div>
          )}
        </main>
        <Footer />
      </div>
    </>
  );
}

Conclusion

With the rise of passkeys coming in the near future, I would suggest that you start learning more about them starting with Hanko.io's Passkeys Authentication. You can find the complete code on GitHub.