- ShadCN UI
- Clerk
- Zod and React Hook Form
- Fetcher anatomy
- OpenAI API
- Replicate AI
- Prisma and mongoose
- API limit for free users
- Stripe
- Other
npx shadcn-ui@latest init
ShadCN UI is a library that allows us to download the components into our own code so we can modify them as we wish. They use class-variance-authority and clsx to proivde typescript typing for our props.
You can add individual components like so:
npx shadcn-ui@latest add button
With the installation of shadcn ui, we also get a handy tailwind class merger helper that allows us to conditionally merge classes:
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
This is an example of how to use it:
<div
className={cn(
"flex items-center gap-4 p-2 hover:bg-slate-600/75 rounded-lg",
link.link === pathname && "bg-slate-600/50"
)}
></div>
npx shadcn-ui@latest add card
npx shadcn-ui@latest add form
npx shadcn-ui@latest add input
With shadcn, we get access to lucide react Icons, which even gives us type definition for those icons, with the LucideIcon
type
import { LucideIcon, MessageSquare } from "lucide-react";
interface Tool {
label: string;
Icon: LucideIcon;
link: string;
color: string;
}
-
Install clerk
npm install @clerk/nextjs
-
Set up env variables by logging into clerk and then
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_b2JsaWdpbmctd2FscnVzLTI4LmNsZXJrLmFjY291bnRzLmRldiQ CLERK_SECRET_KEY=sk_test_HcA0i0usbPAJ7Sn2joMGJ28bDouOiH3a9WTJ2gZFaf
-
Wrap your root layout and entire app in a
<ClerkProvider>
, which is a server component provider that will allow us to use authentication throughout our app.import { ClerkProvider } from "@clerk/nextjs"; export default function RootLayout({ children, }: { children: React.ReactNode, }) { return ( <html lang="en"> <body className={inter.className}> <ClerkProvider>{children}</ClerkProvider> </body> </html> ); }
-
CREAte a
middleware.ts
file to protect all your routes, add add the specified code, exporting defualt theauthMiddleware()
function from clerk to protect all routes. We match all routes by default, and then specify which routes to make public through thepublicRoutes
array.import { authMiddleware } from "@clerk/nextjs"; // Matches all routes and protects them with the authMiddleware export default authMiddleware({ publicRoutes: ["/"], }); export const config = { matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"], };
-
Create a sign in page.tsx at
src\app\sign-in\[[...sign-in]]
import { SignIn } from "@clerk/nextjs"; export default function Page() { return <SignIn />; }
-
Create a sign up page.tsx at
src\app\sign-up\[[...sign-up]]
import { SignUp } from "@clerk/nextjs"; export default function Page() { return <SignUp />; }
-
Add environment variables specifying the auth flow for your app
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
Simply render the <UserButton />
component for a nice profile pic and login actions.
import { auth } from "@clerk/nextjs";
const { userId } = await auth();
import { currentUser } from "@clerk/nextjs";
const user = await currentUser();
Used for route handlers
import { getAuth } from "@clerk/nextjs/server";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextRequest, res: NextResponse) {
const { userId } = getAuth(req);
// Load any data your application needs for the API route
return NextResponse.next();
}
We will install three libraries to have typescript-safe forms
npm i zod react-hook-form @hookform/resolvers
zod
: a type-safe schema completely integrated with TypeScriptreact-hook-form
: a lightweight wrapper for working with forms in react@hookform/resolvers
: a library of connectors (we're going to use the zod one) for integrating schema structure libraries with react hook form.
import * as z from "zod";
export const formSchema = z
.object({
prompt: z
.string()
.nonempty({
message: "Prompt is required",
})
.min(1)
.max(1000),
sayHi: z.string().min(1).max(1000),
confirmHi: z.string().min(1).max(1000),
})
.refine((data) => data.sayHi === data.confirmHi, {
message: "Say hi and confirm hi must be the same",
path: ["confirmHi"],
});
z.object()
: creates a zod object schemaz.string()
: creates a zod string schemaz.nonempty()
: ensures that the string is not emptyz.min()
: ensures that the string is at least a certain length or number is at least a certain valuez.max()
: ensures that the string is at most a certain length or number is at most a certain valuez.array()
: creates a zod array schema
If we want to have stuff like confirm password fields, we should chain on a .refine()
at the end of the schema, which allows us to add custom validation logic. Here are the arguments:
- First argument : a callback function where we can access the data and return a boolean
- Second argument : an object with the following properties
- message : the error message to display
- path : the path to the field that we want to display the error message on
React hook form needs a typescript type if we need typing. We can create a type by inferring the type from a zod schema:
export const formSchema = z.object({
prompt: z
.string()
.nonempty({
message: "Prompt is required",
})
.min(1)
.max(1000),
});
type FormData = z.infer<typeof formSchema>;
The z.infer
function will infer the type from the zod schema, which is easy and great.
First import the zod resolver: import { zodResolver } from "@hookform/resolvers/zod"
Then use the base type as a generic for the useForm()
hook
const { register, handleSubmit } = useForm<FormData>({
resolver: zodResolver(formSchema),
});
The rest is simple
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
type FormData = z.infer<typeof formSchema>;
const PromptForm = () => {
const {
register,
handleSubmit,
formstate: { errors },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
});
const onSubmit = (data: FormData) => {};
return null;
};
This is the basic fetcher function, but we can make it better with typescript.
export async function fetcher({
url,
method,
body,
json = true,
}: FetcherProps) {
const res = await fetch(url, {
method,
body: body && JSON.stringify(body),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error("API Error");
}
if (json) {
const data = await res.json();
return data;
}
}
For the fetcher pattern, this is how it usually goes:
-
Create an enum for all the api routes, to prevent mispellings.
export enum API { CONVERSATION = "/api/conversation", CODE = "/api/code", IMAGE = "/api/image", MUSIC = "/api/music", VIDEO = "/api/video", }
-
Create an interface that has the request body types for all according routes. Make sure each key is a route from the enum.
export interface RequestBodyTypes { [API.CONVERSATION]: { messages: OpenAI.Chat.CompletionCreateParams["messages"], }; [API.CODE]: { messages: OpenAI.Chat.CompletionCreateParams["messages"], }; [API.IMAGE]: { prompt: string, amount: number, resolution: string, }; [API.MUSIC]: { prompt: string, }; [API.VIDEO]: { prompt: string, }; }
-
Create an interface that has the response body types for all according routes. Make sure each key is a route from the enum.
export interface ResponseBodyTypes { [API.CONVERSATION]: { response: OpenAI.Chat.CompletionCreateResponse, }; [API.CODE]: { response: OpenAI.Chat.CompletionCreateResponse, }; [API.IMAGE]: { response: string, }; [API.MUSIC]: { response: string, }; [API.VIDEO]: { response: string, }; }
-
Create an interface for the
fetcher
function:interface FetcherProps { url: | API.CONVERSATION | API.CODE | API.IMAGE | API.MUSIC | API.VIDEO | string; method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; body?: any; json?: boolean; }
-
Create a function that takes in the route and the request body, and returns a promise of the response body. Make sure to type the function with the generic types.
export async function fetcher({ url, method = "POST", body, json, }: { url: API.CONVERSATION; method: "POST"; body: RequestBodyTypes[API.CONVERSATION]; json?: boolean; }): Promise<ResponseTypes[API.CONVERSATION]>;
The OpenAI API should only be used on the server, since your API Key needs to be kept secret.
The one import you need is OpenAI
class from the openai
package. You can then create an instance of the class with your api key. You can then use chat completion or image generation API.
import { OpenAI } from "openai";
const openAi = new OpenAI({
apiKey: process.env.OPENAI_KEY,
});
Use use the openAi.chat.completions.create()
method to create a chat conversation. The method takes in an object with the following properties:
model
: the model to use, as a string.messages
: an array of message objects, each with acontent
key to represent the message content and therole
key to represent what role the message represents.
const response = await openAi.chat.completions.create({
model: "gpt-3.5-turbo",
messages,
});
console.log(response.choices[0].message.content); // the AI response
This method returns a response.
The AI text response is in the response.choices[0].message.content
property.
The messages
is an important property that you need for a chat completion because it represents the entire chat history.
import { ChatCompletionMessage } from "openai/resources/chat";
const instructionMesaage: ChatCompletionMessage = {
content: `You are a code generator. You must answer only in markdown code snippets. Use code comments for explanations. You may deliver a less than 500 word explanation after giving all the code, to help explain the code.`,
role: "system",
};
Each message comes with a content
and a role
property you must fill. The content
is just the message content, but role
is more interesting:
system
: Use thesystem
role when defining the behavior for the model, like when using Act as syntax.user
: Use theuser
role when you are sending a message to chatGpt like asking it a question or just how you normally would.assistant
: Use theassistant
role whenever ChatGPT responds to you
// add our message to the messages array
const newMessages: OpenAI.Chat.CompletionCreateParams["messages"] = [
...messages,
{ content: data.prompt, role: "user" },
];
setMessages(newMessages);
// make AI openAI request, passing messages array
const aiResponse = await fetcher({
url: API.CONVERSATION,
body: { messages: newMessages },
method: "POST",
});
// add AI response to messages array
setMessages([
...newMessages,
{
content: aiResponse.response,
role: "assistant",
},
]);
The openAi.images.generate()
method takes in an object with these properties:
prompt
: the image prompt, Stringn
: the number of images to generate, Numbersize
: the size of the image, must be square and one of these values:"256x256"
,"512x512"
, `"1024x1024"
const { amount, prompt, resolution } = await req.json();
const response = await openAi.images.generate({
prompt,
n: Number(amount),
size: resolution as OpenAI.ImageGenerateParams["size"],
});
The response we get back will be an array of objects with a url
property because of the ability to generate multiple images at a time, so the structure will be like so:
type Response = { url: string }[];
return NextResponse.json({
response: response.data.map((obj) => obj.url),
} as ResponseTypes[API.IMAGE]);
Replicate AI is a platform where you can run open source models of any kind, easliy and on the cheap.
Here is how you get set up after installing with npm i replicate
and getting your API token:
import Replicate from "replicate";
const replicate = new Replicate({
auth: process.env.REPLICATE_API_TOKEN!,
});
You can then run various models by just searching up for how to use the model, and it will always be something like this:
const response: { audio: string, spectogram: string } = await replicate.run(
"riffusion/riffusion:8cf61ea6c56afd61d8f5b9ffd14d7c216c0a93844ce2d82ac1c9ecc9c7f24e05",
{
input: {
prompt_a: prompt,
},
}
);
return NextResponse.json({
response: response.audio,
} as ResponseTypes[API.MUSIC]);
The response structure will differ from model to model
- Install prisma dependencies
npm i prisma -D
npm i -D @prisma/client
npx prisma init
- Create a mongodb atlas database and get the connection string. Make sure to specify a database name in the connection string. Add it to the env
- Have your schema prisma like this:
datasource db {
provider = "mongodb"
url = env("DATABASE_URL")
}
- Create the Schema
model UserApiLimit {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @unique
count Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
For mongodb, we need to map ids to the _id
field, like so:
id String @id @default(auto()) @map("_id") @db.ObjectId
On each API request, before running the openAI model, create/update a prisma object for the user representing a UserApiLimit
, and keep track of the current free generations with a count
property on the document.
When the count
on a user api limit document surpasses the max limit on free generations we set, we return a 400 server error.
We then catch the server error and use a state-management library called Zustand to open a Pro Modal that prompts the user to upgrade their account.
import { create } from "zustand";
// 1. create an interface for type safety
interface useProModalStore {
isOpen: boolean;
closeModal: () => void;
openModal: () => void;
}
// 2. create a store, and pass our interface as the generic type
// 3. Use the set function as a generic setter, where you can pass in state changes
export const useProModalStore = create<useProModalStore>((set) => ({
isOpen: false,
closeModal: () => set({ isOpen: false }),
openModal: () => set({ isOpen: true }),
}));
Follow these steps:
- Go to stripe dashboard and set your dashboard to test mode.
- Copy your publishable key and secret key from stripe into your env variables
- Do
npm i stripe
- Create a stripe instance so you can use the stripe SDK
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2023-08-16",
typescript: true,
});
export default stripe;
- User clicks on the upgrade button
- Request the
/api/pay
route in our server - We check if the user already has a subscription by using Prisma.
- If not, we create a checkout session with stripe, create products, generate a stripe checkout link and redirect the user to the checkout page.
- If yes, we create a billing portal with stripe which allows the user to manage their subscription.
- We return a response with the generated stripe URL, whether it's a checkout link or a billing portal link.
To create a checkout page with stripe, we need the following information:
- The link to redirect to when the purchase is complete successfully. (We'll just do the dashboard)
- The link to redirect to when the purchase is cancelled. (We'll just do the dashboard)
- Any products and the price associated with it
Here is the way we get the redirect link:
const localUrl = "http://localhost:3000";
const productionUrl = process.env.NEXT_PUBLIC_URL;
const serverUrl =
process.env.NODE_ENV === "production" ? productionUrl : localUrl;
When in developer mode, we can create products in the strip dashboard and use their price_id
to refer to the products we created.
Regardless, we will use the stripe.checkout.sessions.create()
method to generate a checkout stripe session.
But we can also create products programmatically, which is what we do below:
const stripeSession = await stripe.checkout.sessions.create({
success_url: `${serverUrl}/dashboard`,
cancel_url: `${serverUrl}/dashboard`,
payment_method_types: ["card", "paypal"],
billing_address_collection: "auto",
customer_email: session.user?.emailAddresses[0].emailAddress,
line_items: [
{
price_data: {
currency: "USD",
product_data: {
name: "Genius AI Subscription",
description: "Genius AI Pro Subscription",
},
unit_amount: 2000,
recurring: {
interval: "month",
},
},
quantity: 1,
},
],
metadata: {
userId: session.userId,
},
mode: "subscription",
});
success_url
: the url to redirect to when the purchase is successfulcancel_url
: the url to redirect to when the purchase is cancelledmode
: the mode of the checkout session, which can bepayment
for one-time purchases orsubscription
payment_method_types
: the payment methods that are allowed. Here it's credit card and paypalcustomer_email
: the email of the customermetadata
: any metadata you want to add to the checkout session. Here we want to pass the user id for integration with our prisma database.line_items
: the products to purchase. Here we only have one product, but we can have multiple products. Each product has aprice_data
object, which has the following properties:currency
: the currency of the productproduct_data
: the product data, which has the following properties:name
: the name of the productdescription
: the description of the product
unit_amount
: the price of the product in centsrecurring
: the recurring object, which has the following properties:interval
: the interval of the subscription, which can beday
,week
,month
,year
The checkout session page url is now on the stripeSession.url
property, which we return as the server's response
return new NextResponse(JSON.stringify({ url: stripeSession.url }));
We see if the user in our database already has a subscription by using prisma. If they do, we create a billing portal session with stripe for them to manage their subscription, and return the portal url to the billing portal session.
export async function GET(req: NextRequest) {
// find user subscription in our database
const userSub = await prisma.userSubscription.findUnique({
where: {
userId: session.userId,
},
});
// if user subscription already exists, create a billing portal session to manage subscription
if (userSub && userSub.stripeCustomerId) {
const stripeSession = await stripe.billingPortal.sessions.create({
customer: userSub.stripeCustomerId,
return_url: `${serverUrl}/dashboard`,
});
return new NextResponse(JSON.stringify({ url: stripeSession.url }));
}
}
const stripeSession = await stripe.billingPortal.sessions.create({
customer: userSub.stripeCustomerId,
return_url: `${serverUrl}/dashboard`,
});
The stripe.billlingPortal.sessions.create()
method creates a billing portal, returning an object with the link of the billing portal. It takes in an object with the following properties:
customer
: the stripe customer idreturn_url
: the url to redirect to when the user is done managing their subscription
You can then access the billing portal URL with stripeSession.url
When in production, make sure to enable the billing portal by going to https://dashboard.stripe.com/test/settings/billing/portal
Stripe webhooks are used to run some logic immediately after a user pays or uses stripe on your website. This is good for automatically running logic and database calls related to user subscriptions.
First install the stripe CLI. I already did this for you. You're welcome, me from the future.
- Login with stripe using
stripe login
- Start the stripe webhook listener with
stripe listen --forward-to localhost:3000/<webhook-route-here>
.- I defined my webhook route as
/api/stripe/webhook
, so I would runstripe listen --forward-to localhost:3000/api/stripe/webhook
- I defined my webhook route as
- Running the command spits out a webhook secret. Copy the webhook signing secret and add it to your env variables
- Your webhook should no be up and running.
The webhook route should be a POST request handler, which gets back information from req.text()
body.
- Get the request body text and the
stripe-signature
header, which are used to construct information about the stripe event:
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
- Construct a stripe event
- Based on the event type, run different logic.
- Return a 200 response with a null body to acknowledge receipt of the event.
import stripe from "@/lib/StripeInstance";
import { prisma } from "@/lib/db";
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
export async function POST(req: NextRequest) {
// 1. get stripe data
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
// 2. create stripe event
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.log(err);
return new NextResponse("Webhook Error", { status: 400 });
}
const session = event.data.object as Stripe.Checkout.Session;
// Payment is successful and the subscription is created.
if (event.type === "checkout.session.completed") {
// Fulfill the purchase...
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
// an unauthorized user without a userId tried checking out something. We can't have that.
if (!session.metadata?.userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
await prisma.userSubscription.create({
data: {
userId: session.metadata.userId,
stripeCustomerId: session.customer as string,
stripeSubscriptionId: subscription.id as string,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(
subscription.current_period_end * 1000
),
},
});
}
// when the new billing interval for the subscription succeeds
if (event.type === "invoice.payment_succeeded") {
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await prisma.userSubscription.update({
where: {
stripeSubscriptionId: subscription.id as string,
},
data: {
stripeCurrentPeriodEnd: new Date(
subscription.current_period_end * 1000
),
stripePriceId: subscription.items.data[0].price.id,
},
});
}
// return empty 200 response to acknowledge receipt of the event
return new NextResponse(null, { status: 200 });
}
If the user is subscribed, we do not want to limit their access. The first thing we can do is create a helper function that tells us whether the current user is subscribed or not.
export async function userIsSubscribed() {
const { userId } = auth();
if (!userId) return false;
const userSub = await prisma.userSubscription.findUnique({
where: {
userId,
},
select: {
stripeCustomerId: true,
stripeCurrentPeriodEnd: true,
stripeSubscriptionId: true,
},
});
if (!userSub) return false;
// if the end period date is in the future, the user has an active subscription
if (userSub.stripeCurrentPeriodEnd! > new Date(Date.now())) return true;
return false;
}
Then in each of our openAI route handlers, only increase the api limit count if the user is not subscribed:
const isPro = await userIsSubscribed();
if (!isPro) {
await increaseApiLimit();
}
const [isMounted, setIsMounted] = useState(true);
useEffect(() => {
// Cleanup function to set isMounted to false when component unmounts
setIsMounted(true);
return () => {
setIsMounted(false);
};
}, []);
if (!isMounted) {
return null;
}