Let’s make a custom, AI generated React component based on user data!

Varga György Márk
Shiwaforce
Published in
13 min readJun 26, 2024

Imagine visiting a website where every page feels like it was designed just for you, where each piece of content speaks to your interests and preferences. In 2024, this dream is becoming a reality as we strive to offer users a unique, personalized experience. After all, who wouldn’t love a digital space that seems to understand and cater to their individual tastes and needs?

What we are going to build?

We will create a web application in which a personalized installment payment calculator will appear after Github login, based on the Github bio of the person who has just logged in. The appearance of the card and the subject of the installment payment itself will be personalized. All this by using the Vercel AI SDK to stream the component from the server.

Let’s take a look at what technologies we will use to implement this small example application:

So, as usual, we will use only the latest web technologies. During the implementation, we will carefully go through all the elements of the technology stack and look at exactly what we need and why we need it.

But before we dive into the implementation, let’s take a look at what we’re going to build.

Okay, so here you can see on the image, that this component probably get generated for a Github user who is interested in sustainable lifestyle (which we all must be interested in anyways).

Stay with me and let’s see the steps of making this app. In the meantime, let’s learn how to use the individual solutions, so that you can later integrate this functionality into your products.

The implementation

Let’s get into it! First, let’s create a Next.js application. Follow this with me:

npx create-next-app@latest

Leave the default settings, except for Eslint, because we will use something else instead. We will be using Biomejs instead of Tailwind and Prettier, even if the automatic Tailwind class sorting is not yet fully resolved.
So, my configuration for starting the project looks like this:

You might have spotted the project has a slightly familiar name. There’s gonna be a few buzzwords you might recognize, please don’t take these too seriously! Adding a buzzword like BNPL (Buy Now Pay Later) can be a fun touch, even though Apple has rolled out this service.
Let’s open our project in our favorite IDE. Our first step will be to install Biome. But what is a Biome? Biome JS is used to optimize JavaScript and TypeScript code. Its functions include code formatting, linting, and speeding up development processes. It replaces both Eslint and Prettier for us, all faster. There is a very good picture that came across me on X last time:

That sums things up nicely I think. Let’s install Biome in our editor. There is also a simple description HERE. The next step is to issue the following command on our project:

npm install --save-dev --save-exact @biomejs/biome

Then we continue by creating the config file:

npx @biomejs/biome init

This command creates a file for us in which we can make various configuration settings. We can leave it at the default settings, but I modified the formatting a bit:

{
"$schema": "https://biomejs.dev/schemas/1.8.2/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2
}
}

After that, it is advisable to integrate in our IDE that a formatting is done for every save. HERE we can download the plugin that suits us, then we can set it up in our IDE so that formatting takes place on the fly. For example, in Webstorm the configuration looks like this:

If we got this far, it’s great, we can already enjoy fast formatting with Biome, and for this we only had to drag in one dependency.

The next step will be to install Auth.js. Here too, we will have a fairly simple task, as the new Auth.js documentation is very handy. We will use this package to authenticate the user with their Github profile. Of course, other Oauth 2 providers could be used, but Github provides us with one of the simplest APIs out there and probably every one of use have a Github profile as developers. Our command is:

npm install next-auth@beta

And then this:

npx auth secret

This will be our secret code, which will be used by Auth.js. After issuing this command, we will receive the message that the secret is ready and copy it to our .env file, which of course we must create first. So .env:

AUTH_SECRET=<your_secret>

Then we proceed according to the official documentation. In the root of our project, we need to create an auth.ts file with the following content:

import NextAuth from "next-auth"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [],
})

And then create this file in the specified location to be created:

./app/api/auth/[…nextauth]/route.ts

Fill it with this content:

import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers

Next, we set up Github as an OAuth provider so that the user can access our application with his or her account. As we have seen, the providers array is currently empty.

In order to be able to authenticate our user with Github Oauth, we need to make some settings within Github. To do this (if you are already logged in to Github) go to THIS link. Then click on New Oauth App. We are greeted by this screen, which we fill out as shown in the picture:

After clicking on the Register application, we can access the Client ID and generate the Client Secret. Let’s do this and copy these into our .env file in the form below:

AUTH_GITHUB_ID=<your_client_id>
AUTH_GITHUB_SECRET=<your_client_secret>

Then, modify the auth.ts file to include the Github login integration:

import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"

export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [GitHub],
})

We’ve had it pretty simple so far, haven’t we? Now let’s implement this on the application interface as well. We will also need a Login button and an action behind the button, where the user authenticates with a button press. app/page.tsx will look like this:

import { signIn } from "@/auth";

export default async function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 w-full max-w-5xl items-center justify-between text-sm lg:flex">
<form
action={async () => {
"use server";
await signIn();
}}
>
<button type="submit">Sign in</button>
</form>
</div>
</main>
);
}

What is going on here? We simply create a form that, with the “use server” directive of Next.js, calls a login interface when the form is submitted, which is provided by Auth.js. Furthermore, it is advisable to remove the frills inserted by the Next.js basic template from globals.css, so that only these remain in that file:

@tailwind base;
@tailwind components;
@tailwind utilities;

After making these changes, start the application (if you haven’t already done so) with the “dev” script in package.json. Our application is already running on http://localhost:3000 and we see a “Sign in” inscription (which is a button by the way). This is already very good progress. Modify page.tsx as follows:

import { auth, signIn } from "@/auth";

export default async function Home() {
const session = await auth();

return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="flex max-w-5xl items-center justify-center text-sm">
{session ? (
<p>User logged in</p>
) : (
<form
action={async () => {
"use server";
await signIn();
}}
>
<button
className="p-3 border rounded-md hover:bg-gray-50"
type="submit"
>
Sign in
</button>
</form>
)}
</div>
</main>
);
}

Here, when the session becomes available from Auth.js, we write that the user is logged in, and if the user is not logged in, we display our login form.
Also, we added a bit of design to make our site look like something, but that’s not the main focus here. Currently do not login, we will make some modification later with the auth flow.

So what will be the next step? Let’s just remember what our goal is: An interface that uses AI to generate a dynamic installment calculator based on the currently logged in user’s Github bio.

What else do we need for this?

  • We need to be able to request the bio of the currently logged-in user from Github.
  • We need a component that contains the slider itself, where the user can set how many months he or she wants to pay for the item to be purchased.
  • We’ll need Vercel’s AI SDK to stream the component.
  • We need the package called “ai”, with which we can also generate text.
  • We need an OpenAI API key

Let’s start from the end of the list. As usual, we need an API key, which we can generate HERE.
If you have it, put it in the .env file:

OPENAI_API_KEY=<your_api_key>

Now let’s start installing the still necessary packages:

npm i ai

and

npm i @ai-sdk/openai

Next, let’s create a small component that we will display until our customized component is displayed to the user. Let’s create a folder in the root of our project called ‘components’ and in it create this file: InstallmentLoading.tsx

export const InstallmentLoading = () => (
<div className="animate-pulse p-4">
Getting your personalised installment offer...
</div>
);

Then we need the component itself, which is basically this:

"use client";

import { type ChangeEvent, useState } from "react";

interface InstallmentProps {
styles: any;
productItem: { item: string; price: number; emoji: string };
}

export const Installment = (props: InstallmentProps) => {
const [months, setMonths] = useState(1);

const handleSliderChange = (event: ChangeEvent<HTMLInputElement>) => {
setMonths(Number(event.target.value));
};

const monthlyPayment = (props.productItem.price / months).toFixed(2);

return (
<div
className="p-6 bg-white shadow-md rounded-lg max-w-md mx-auto"
style={props.styles}
>
<div className="flex mb-4 justify-center text-lg space-x-3">
<span className="font-semibold">{props.productItem.item}</span>
<span>{props.productItem.emoji}</span>
</div>
<div className="mb-4 text-lg">
Toal Amount:{" "}
<span className="font-semibold">${props.productItem.price}</span> USD
</div>
<div className="mb-4">
<label htmlFor="months" className="block text-sm mb-2">
Installment Duration (in months):{" "}
<span className="font-semibold">{months}</span>
</label>
<input
type="range"
id="months"
name="months"
min="1"
max="60"
value={months}
onChange={handleSliderChange}
className="w-full"
/>
</div>
<div className="text-lg">
Your monthly payment is:{" "}
<span className="font-semibold">${monthlyPayment}</span> USD
</div>
</div>
);
};

Here we can see that there are a lot of variables that we will have to replace. These will be the ones that the AI ​​generates for us based on the user’s Github bio.
And then comes the core part of things. Let’s create an actions.tsx file in the app directory. This is where the server action comes in, which will generate our component for us on the server side using the Vercel AI SDK. This is what the file will look like:

"use server";

import { Installment } from "@/components/Installment";
import { InstallmentLoading } from "@/components/InstallmentLoading";
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
import { streamUI } from "ai/rsc";
import { z } from "zod";

const getInstallmentCardStyle = async (bio: string) => {
const { text } = await generateText({
model: openai("gpt-4o"),
prompt:
`Generate the css for an installment offer component card that would appeal to a user based on this bio in his or her Github profile: ${bio}.
Please do not write any comments just write me the pure CSS. Please post only the css key value pairs and make the design modern and elegant!
Here is the component div that you should style.` +
'<div style="here">You payment details</div>',
});

if (text) {
const cssWithoutSelectors = text
.replace(/div:hover \{([^}]*)\}/g, "$1")
.replace(/div \{([^}]*)\}/g, "$1");
return cssWithoutSelectors.trim();
}

return "";
};

const getProductSuggestion = async (bio: string) => {
const { object } = await generateObject({
model: openai("gpt-4o"),
schema: z.object({
product: z.object({
item: z.string(),
price: z.number(),
emoji: z.string(),
}),
}),
prompt: `Please write a piece of advice on what a user can buy on credit, whose Github bio is this ${bio}.
Also write the expected price for the item and a suggested emoji.`,
});

return object.product;
};

const cssStringToObject = (cssString: string) => {
const cssObject: { [key: string]: string } = {};
const cssArray = cssString.split(";");

cssArray.forEach((rule) => {
const [property, value] = rule.split(":");
if (property && value) {
const formattedProperty = property
.trim()
.replace(/-./g, (c) => c.toUpperCase()[1]);
cssObject[formattedProperty] = value.trim();
}
});

return cssObject;
};

export async function streamComponent(bio: string) {
const result = await streamUI({
model: openai("gpt-4o"),
prompt: "Get the loan offer for the user",
tools: {
getLoanOffer: {
description: "Get a loan offer for the user",
parameters: z.object({}),
generate: async function* () {
yield <InstallmentLoading />;
const styleString = await getInstallmentCardStyle(bio);
const style = cssStringToObject(styleString);
const product = await getProductSuggestion(bio);
return <Installment styles={style} productItem={product} />;
},
},
},
});

return result.value;
}

The function names speak for themselves, but let’s take a look at which one does what more precisely.
The function of getInstallmentCardStyle is to return the personalized design of the installment card that the user is likely to be interested in. Here we use generateText from the ai library provided by Vercel, which simply connects to the gtp-4o model, which returns the response based on the given prompt. Then we format the output a bit.

getProductSuggestion, also with a telling name, calls the AI SDK’s generateObject function since here we need the recommended product with a predefined format.

Our next function is cssStringToObject, which is a helper function we use in the next streamComponent function. This properly formats the css string returned by OpenAI into an object that we can then use in the component.

And finally, the previously mentioned streamComponent. This function will be the part where we can stream the client component using the streamUI function of the Vercel AI SDK. You can see that within generate we put together the values ​​needed to create our Installment component. We can also notice that one of the parameters of the streamComponent is the github bio. We have reached the point where we query the Github bio of the logged-in user and call this action with it.

So to make it all come together, let’s go to the app/page.tsx file. Our goal here would be that if the user has already logged in, the user can see his personalized component. But how do we request the Github bio of the logged-in user? For this, we also need to study the Github API a bit. They have such an endpoint. With this, we can query the data of the currently logged-in user. This is exactly what we need. However, as we read the documentation, we can see that we also need an access token, which we attach to the API query. To do this, we need to slightly modify our auth.ts file, where after logging in, we need to put the access_token received during the github Oauth login into the session managed by Auth.js. Change the content of the auth.ts file to:

declare module "next-auth" {
interface Session {
accessToken?: string;
}
}

import NextAuth from "next-auth";
import Github from "next-auth/providers/github"

export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [Github],
callbacks: {
async jwt({token, user, account}) {
if (account) {
token.accessToken = account.access_token;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
return session;
}
}
});

It is important that we can save the accessToken in this file in our session so that we can call the Github api from our server component on the main page. So let’s go back to app/page.tsx:

 import { streamComponent } from "@/app/actions";
import { auth, signIn } from "@/auth";

export default async function Home() {
const session = await auth();
let accessToken;
let responseJson;

if (session) {
accessToken = session.accessToken;

const response = await fetch(`https://api.github.com/user`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});

responseJson = await response.json();
}

return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="flex max-w-5xl items-center justify-center text-sm">
{session ? (
<div>{await streamComponent(responseJson.bio)}</div>
) : (
<form
action={async () => {
"use server";
await signIn();
}}
>
<button
className="p-3 border rounded-md hover:bg-gray-50"
type="submit"
>
Sign in
</button>
</form>
)}
</div>
</main>
);
}

Since we put the accessToken in the session, we can reach this here and call Github’s API, from which we can extract the bio of the logged-in user and match it to our streamComponent function.

Testing the application

Now all that’s left is to test the app we’ve created. Let’s have a look! First of all, let’s set up a good Github bio description. I also have a good “fake” idea. Last weekend, I watched (again) the Matrix trilogy and played a lot with my Meta Quest 3 VR headset, so the following description is self-explanatory:

Cool, right? Well, let’s see what will be served to me on the main page depending on this!
On the http://localhost:3000 page, go to the “Sign in” button, then click the “Sign in with Github” button. After logging in, I can already see that I have received my Matrix themed component, which offers me a Meta Quest 2. After all, it’s right, because I don’t have Quest 2, only Quest 3. 😁

Real Matrix design! Well, it’s not a look you’d expect from a modern component, but it fits right into the world of The Matrix, as their website looked similar in 1999.

Conclusion and source code

We have reached the point where I can share with you the code of the entire project, which you can find HERE on Github.

Of course, this can be further developed in many ways, let’s look at a few options:

  • We candeploy it to Vercel!
  • Introduction of AI caching with Vercel KV
  • Creating several basic components for the AI ​​to choose from
  • TailwindCSS — Shadcn/ui generation instead of vanilla CSS
    and if you have any more ideas, write them in the comments or make a Github pull request! :)

If you have any questions, feel free to write a comment, e-mail me at gyurmatag@gmail.com or contact me on Twitter, I will be happy to help and answer!

Many thanks to Shiwaforce for their support and resources in the creation of this article.

--

--