DEV Community

Cover image for Let's make a custom, AI generated React component based on user data!
Varga György Márk for Shiwaforce

Posted on

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

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.

Image description

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

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:

Image description

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:

Image description

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

Enter fullscreen mode Exit fullscreen mode

Then we continue by creating the config file:

npx @biomejs/biome init
Enter fullscreen mode Exit fullscreen mode

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

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:

Image description

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

And then this:

npx auth secret

Enter fullscreen mode Exit fullscreen mode

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

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: [],
})
Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

Fill it with this content:

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

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:

Image description

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

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

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

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

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

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>

Enter fullscreen mode Exit fullscreen mode

Now let's start installing the still necessary packages:

npm i ai

Enter fullscreen mode Exit fullscreen mode

and

npm i @ai-sdk/openai

Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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:

Image description

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. 😁

Image description

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 can deploy 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.

Top comments (0)