Integrating Strapi with ChatGPT and Next.js

Michael - Apr 12 - - Dev Community

Introduction

In this tutorial, we will learn how to use Strapi, ChatGPT, and Next.js to build an app that displays recipes using AI.

This tutorial aims to show how to combine these different technologies and how integrating AI can enrich the user experience of our apps.

For example, in a recipe-sharing app, a content management system such as Strapi would be in charge of structured storage and management of the data about each recipe. By integrating AI, we can elevate the functionality with additional insights, suggestions, or personalisation according to the particular user.

Prerequisites

To follow this tutorial, we will need the following:

  • An OpenAI (ChatGPT) account and API key.
  • Package manager (yarn or npm).
  • Node.js (v16 or v18).
  • Code editor (Visual Studio code, Sublime).
  • Basic knowledge of Javascript.

Overview of Strapi

Strapi or Strapi CMS is an open-source headless content management system that allows us to create APIs quickly. The headless architecture separates the content management and content delivery layers. This feature will enable us to consume the content via API and use whatever frontend technology (React.Js, Vue, etc.). Strapi CMS also provides plugins or Strapi plugins that can extend the functionality and add custom features to the CMS. The role of Strapi in this project will be to serve as the backend, which is responsible for storing and serving recipes to the frontend. So, without further ado, let's dive into the code.

Setting up Strapi

Let's create a folder that will contain the source code for our Strapi API. First, we open a terminal and navigate to any directory of our choice, and run the commands below:

mkdir strapi-tutorial
cd strapi-tutorial
Enter fullscreen mode Exit fullscreen mode

This folder will contain both our frontend and backend codes. Now, let's create our Strapi API with the command below:

npx create-strapi-app@latest strapi-recipe-api
Enter fullscreen mode Exit fullscreen mode

Once this is complete and it finishes installing the dependencies, we should receive a confirmation like the one below!

001-strapi-installation.png

It should automatically direct us to the Strapi dashboard as it starts our project. We can create our administrator here, so we must fill out the form and click the "Let's start" button.

We should now see the dashboard which looks like this:

002-strapi-welcome-on-board.png

Create Collection Type

We want to create the collection type, which would be the recipe and its various keys and values, such as ingredients and instructions.

Click Content-Type Builder from the left side panel, and then on the left, under Collection Types, we should see "Create new collection type".  Click on that and type "Recipe" into the display name.

003-create-collect-type.png

Click continue, and from here, click text as the data type like this:

004-select-field-for-collection-type.png

Then enter the title and make sure short text is selected.

005-add-new-text-field.png

Now we need to add the rest of the fields, so click on "Add another field," add text, and enter a description, but this time select the long text. The rest of the fields to add are like so:

  • ingredients - long text
  • instructions - long text

After adding instructions, we click "Finish." We should now see our newly created collection: Recipe and its different fields. Click save at the top right. This action will restart the server. Once done, we can play around with our collection and add some recipes.

From the left side panel, now select Content Manager under Collection Types, select Recipe, and then select "create new entry" on the top right.

We can ask ChatGPT to generate the inputs for us with the following prompt: "Give me a recipe idea with a title, description, ingredients, and instructions" and fill in the various fields below:

006-create-an-entry.png

Now go ahead and click save in the top right. This action should prompt a success message, and now, if we click Recipe on the left, we should see our latest recipe entry in the table.

Tick the entry, and then select Publish:

007-recipe-collection-type-with-entries.png

By default, Strapi requires authentication to query our API and receive information, but that is outside the scope of this tutorial. Instead, we will make our API publicly accessible. We can find more about authentication and REST API in this blog post.

From the left sidebar, click on Settings. Again,  on the left panel under USERS & PERMISSIONS PLUGIN, click on Roles, then click on Public from the table on the right. Now scroll down, click on Recipe, and tick Select all to allow the user to access information without authentication.

008-allow-public-access.png

Now if we copy and paste the following into our browser we should be able to fetch the data from our API.

http://localhost:1337/api/recipes
Enter fullscreen mode Exit fullscreen mode

Integrating ChatGPT with Strapi

So far we've learned what Strapi is and what it does, we've got the API up and running and added some data, now let's see how we can integrate ChatGPT in our project.

First navigate to the terminal and run the below command in the root directory:

yarn strapi generate
Enter fullscreen mode Exit fullscreen mode

This will begin the process of generating our own custom API. Choose the API option, give it the name recipe-gpt, and select "no" when it asks us if this is for a plugin.

Inside the src directory, If we check the api directory in our code editor, we should see the newly created APIs for recipe and recipe-gpt with their routes, controllers, and services.

Let's check it works by uncommenting the code in each file, restarting the project in the terminal, and navigating to the admin dashboard. Now, once again, click Settings > Soles > Public, then scroll down to Select all on the recipe-gpt API to make the permissions public, and click save in the top right.

0010-welcome-to-nextjs.png

Now if we enter the following into our browser and click enter, we should get an "ok" message.

http://localhost:1337/api/recipe-gpt
Enter fullscreen mode Exit fullscreen mode

Okay, now we've confirmed the API endpoint is working, let's connect it to OpenAI first, install the OpenAI package, navigate to the route directory, and run the command below in our terminal

yarn add openai
Enter fullscreen mode Exit fullscreen mode

Then in the .env file add the following environment variable:

OPENAI=< OpenAI api key here>
Enter fullscreen mode Exit fullscreen mode

Now under the recipe-gpt directory change the code in the routes directory to the following:

module.exports = {
  routes: [
    {
      method: "POST",
      path: "/recipe-gpt/exampleAction",
      handler: "recipe-gpt.exampleAction",
      config: {
        policies: [],
        middlewares: [],
      },
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

Change the code in the controller directory to the following:

"use strict";

module.exports = {
  exampleAction: async (ctx) => {
    try {
      const response = await strapi
        .service("api::recipe-gpt.recipe-gpt")
        .recipeService(ctx);

      ctx.body = { data: response };
    } catch (err) {
      console.log(err.message);
      throw new Error(err.message);
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

And the code in the services directory to the following:

"use strict";
const { OpenAI } = require("openai");
const openai = new OpenAI({
  apiKey: process.env.OPENAI,
});

/**
 * recipe-gpt service
 */

module.exports = ({ strapi }) => ({
  recipeService: async (ctx) => {
    try {
      const input = ctx.request.body.data?.input;
      const completion = await openai.chat.completions.create({
        messages: [{ role: "user", content: input }],
        model: "gpt-3.5-turbo",
      });

      const answer = completion.choices[0].message.content;

      return {
        message: answer,
      };
    } catch (err) {
      ctx.body = err;
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

Now we can make a post request which contains input (which will be from our frontend) and return answers from chatGPT.

We can check the connection to our post route by pasting the below code in our terminal:

curl -X POST \
  http://localhost:1337/api/recipe-gpt/exampleAction \
  -H 'Content-Type: application/json' \
  -d '{
    "data": {
        "input": "Give me some interesting nutritional information"
    }
}'

Enter fullscreen mode Exit fullscreen mode

Implement Front-end Components with Next.Js

So we now have the API set up to deliver content (recipes) and a custom API set up to provide information from ChatGPT on any input we might send to it from the frontend. Let's move on to the next part (whoops, no pun intended).

Next.js is a React framework that simplifies the development of complex and high-performance web applications. It offers many built-in features, such as server-side rendering, automatic code splitting, automatic image optimization, and API routes. Next.Js streamlines the development process and ensures fast and responsive user experiences.

Let's create our frontend directory. Navigate to the main folder, strapi-tutorial, and enter the following command in our terminal.

npx create-next-app recipe-frontend
Enter fullscreen mode Exit fullscreen mode

Navigate into this newly created directory and run the below

yarn dev
Enter fullscreen mode Exit fullscreen mode

This should start and run the project in http://localhost:3000, when accessed through the web browser, we should be able to see the image below:

009-custom-api.png

Open up the project in our code editor and look under the pages directory and click the index.js file. This file is the entry point to the application and where the code for this welcome page exists. Delete everything and paste the below code into this file.

import Head from 'next/head';
import styles from '../styles/Home.module.css';

export default function Home() {
  return (
    <div className={styles.container}>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <h1 className={styles.title}>
          Welcome to <a href="https://nextjs.org">Strapi Recipe!</a>
        </h1>
      </main>

      <footer className={styles.footer}>
        <a
          href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          Powered by Strapi
        </a>
      </footer>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Save the file. We should notice the difference in our web browser as the code automatically reloads.

Next, we will want to create some re-usable components to display the recipes for our users. To speed things up, let's use a component library. Navigate to the terminal, and run the command below

yarn add antd
Enter fullscreen mode Exit fullscreen mode

First, let's add a card component. Create a directory components outside of the pages directory and then create a file called RecipeCard.jsx. Paste the following code inside the new file:

import { Card } from 'antd';
import Image from 'next/image';

export default function RecipeCard({ title, bordered, content }) {
  return (
    <div style={{ cursor: 'pointer', width: '400px' }}>
      <Card
        title={title}
        bordered={bordered}
        cover={
          <Image
            alt="example"
            unoptimized
            width={400}
            height={200}
            src={'https://placehold.co/400x200'}
          />
        }
      >
        {content.length > 200 ? content.slice(0, 200) + '...' : content}
      </Card>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

By default Next.Js reccomends us to use the images.domains to be able to load external images with next/image, so just add the following to our next.config.js file

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
  images: {
    domains: ['placehold.co'],
  },
};

module.exports = nextConfig;
Enter fullscreen mode Exit fullscreen mode

Let's create a way to view the details of our recipes using a modal from the component library, create a file in components directory called RecipeModal.jsx and paste in the below code

import { Modal } from 'antd';

export default function RecipeModal({
  title,
  open,
  setOpen,
  width = 1000,
  description,
  ingredients,
  instructions,
}) {
  return (
    <div>
      <Modal
        title={title}
        centered
        open={open}
        onOk={() => setOpen(false)}
        onCancel={() => setOpen(false)}
        width={width}
      >
        <h3>Description</h3>
        <p>{description}</p>
        <h3>Ingredients</h3>
        <p>{ingredients}</p>
        <h3>Instructions</h3>
        <p>{instructions}</p>
      </Modal>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now, we have our components for displaying the recipes we can move on to fetching our content from Strapi and working out the logic to connect the data and the UI together.

Fetching and displaying recipe data

So, now that we have Strapi ready with our API and we've set up the frontend Next.Js app and built some components to host our data, we're finally ready to make the connection. We will want to create some reusable piece of logic that encapsulates the fetching of our data and the storing in state; this is a perfect use for a custom hook.

First, let's create the fetch function for our API, so create a utils folder in the root directory. Inside this new folder, create a file called api.js, and paste the below code there.

const baseUrl = 'http://localhost:1337';

export async function fetchRecipes() {
  try {
    const res = await fetch(`${baseUrl}/api/recipes`);
    return await res.json();
  } catch (e) {
    console.error('Error fetching data:', error);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

In the root directory create a hooks folder, and inside that, create a file named useGetRecipe.js and add the following code:

import { useEffect, useState } from 'react';
import { fetchRecipes } from '../utils/api';

export function useGetRecipe() {
  const [recipes, setRecipes] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchData() {
      try {
        const { data } = await fetchRecipes();
        console.log('data - ', data);
        setRecipes(data);
      } catch (e) {
        setError(e);
      } finally {
        setLoading(false);
      }
    }

    fetchData();
  }, []);

  return { recipes, loading, error };
}
Enter fullscreen mode Exit fullscreen mode

This hook encapsulates all of the logic to call our API, set the loading state while we wait for data, set the data in the state, and save any errors we might get. We can easily re-use this hook without re-writing this logic anywhere else.

Import it into index.js and use it to display our RecipeCard component. The index.js file should now look like the code below:

import Head from 'next/head';
import styles from '../styles/Home.module.css';
import { useGetRecipe } from '../hooks/useGetRecipe';
import dynamic from 'next/dynamic';
const RecipeCard = dynamic(() => import('../components/RecipeCard'), {
  ssr: false,
});

export default function Home() {
  const { recipes, loading, error } = useGetRecipe();

  return (
    <div className={styles.container}>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <div>
          <h1 className={styles.title}>
            Welcome to <a href="https://nextjs.org">Strapi Recipe!</a>
          </h1>
        </div>
        <div className={styles.recipeContainer}>
          {!loading && recipes ? (
            recipes.map((recipe, i) => {
              const { title, description } = recipe.attributes;
              return (
                <RecipeCard
                  key={i}
                  title={title}
                  bordered={true}
                  content={description}
                />
              );
            })
          ) : (
            <p>...Loading</p>
          )}
        </div>
      </main>

      <footer className={styles.footer}>
        <a
          href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          Powered by Strapi
        </a>
      </footer>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Add the following styles to the Home.module.css file that is inside the styles directory

.recipeContainer {
  margin-top: 50px;
  display: flex;
  flex-wrap: wrap;
}

.item {
  margin: 5px;
}
Enter fullscreen mode Exit fullscreen mode

Now, if we reload the project and navigate to localhost we should be able to see the recipe we added as shown below:

011-recipe-app-homepage.png

Now let's try and use the modal so we can click on the recipe and view more details about it.

We will just need to import our RecipeModal component, add a handleClick method and save our recipe in state. The index.js component should now look as below:

import { useState } from 'react';
import Head from 'next/head';
import styles from '../styles/Home.module.css';
import { useGetRecipe } from '../hooks/useGetRecipe';
import dynamic from 'next/dynamic';
const RecipeCard = dynamic(() => import('../components/RecipeCard'), {
  ssr: false,
});
const RecipeModal = dynamic(() => import('../components/RecipeModal'), {
  ssr: false,
});

export default function Home() {
  const { recipes, loading, error } = useGetRecipe();
  const [recipe, setRecipe] = useState(null);
  const [open, setOpen] = useState(false);

  const handleCardClick = (recipeToView) => {
    setRecipe(recipeToView);
    setOpen(true);
  };

  return (
    <div className={styles.container}>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <div>
          <h1 className={styles.title}>
            Welcome to <a href="https://nextjs.org">Strapi Recipe!</a>
          </h1>
        </div>
        <div className={styles.recipeContainer}>
          {!loading && recipes ? (
            recipes.map((recipe, i) => {
              const { title, description } = recipe.attributes;
              return (
                <div
                  onClick={() => handleCardClick(recipe.attributes)}
                  key={i}
                  className={styles.item}
                >
                  <RecipeCard
                    title={title}
                    bordered={true}
                    content={description}
                  />
                </div>
              );
            })
          ) : (
            <p>...Loading</p>
          )}
        </div>
        <RecipeModal
          title={recipe?.title}
          open={open}
          setOpen={setOpen}
          description={recipe?.description}
          ingredients={recipe?.ingredients}
          instructions={recipe?.instructions}
        />
      </main>

      <footer className={styles.footer}>
        <a
          href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          Powered by Strapi
        </a>
      </footer>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now if we navigate to localhost and click on a recipe we should be able to see all of it's details.

012-modal-for-recipe-details.png

We have connected our data from Strapi with our frontend components and everything seems to be working smoothly, feel free to add more recipes from the Strapi dashboard and watch them appear in the frontend. Next up, let's use our API connected to ChatGPT to enhance the functionality of our app.

Enhancing users experience with AI suggestions

Let's enhance the functionality by adding an extra button to our modal allowing users to make the dish plant-based. We can do this by sending the ingredients of the recipe to our ChatGPT API, which will then provide us with suggestions to change them to plant-based alternatives.

First things first, let's set up the API call with fetch in the utils directory. Add the code below to api.js.

export async function fetchRecipeGPTData(ingredients) {
  try {
    const response = await fetch(`${baseUrl}/api/recipe-gpt/exampleAction`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        data: {
          input: `Here are the ingredients for a recipe ${ingredients}, provide me with a plant based version`,
        },
      }),
    });

    return await response.json();
  } catch (e) {
    console.error('Error fetching data:', error);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

As we can see, we are just passing the ingredients in with a simple prompt to GPT.

Next under the hooks directory create a file named useRecipeGPTData.js and pass in the below code

import { useState } from 'react';
import { fetchRecipeGPTData } from '../utils/api';

export function useRecipeGPTData() {
  const [data, setData] = useState();
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState();

  const fetchData = async (ingredients) => {
    setIsLoading(true);
    try {
      const res = await fetchRecipeGPTData(ingredients);
      const dataToSave = res.data.message;

      setData(dataToSave);
    } catch (e) {
      setError(e);
    } finally {
      setIsLoading(false);
    }
  };
  return { data, fetchData, isLoading, setData, error };
}
Enter fullscreen mode Exit fullscreen mode

Now we can use this hook to prompt GPT from anywhere in our app.

All that's left to do is to connect this function to our modal so we can allow users to query it.

In index.js, import useRecipeGPTData and pass some of the functions in to the RecipeModal. The index.js file should now look like this

import { useState } from 'react';
import Head from 'next/head';
import styles from '../styles/Home.module.css';
import { useGetRecipe } from '../hooks/useGetRecipe';
import { useRecipeGPTData } from '../hooks/useRecipeGPTData';
import dynamic from 'next/dynamic';
const RecipeCard = dynamic(() => import('../components/RecipeCard'), {
  ssr: false,
});
const RecipeModal = dynamic(() => import('../components/RecipeModal'), {
  ssr: false,
});

export default function Home() {
  const { recipes, loading, error } = useGetRecipe();
  const { data, fetchData, isLoading, setData } = useRecipeGPTData();
  const [recipe, setRecipe] = useState(null);
  const [open, setOpen] = useState(false);

  const handleCardClick = (recipeToView) => {
    setRecipe(recipeToView);
    setOpen(true);
  };

  const handleCloseModal = () => {
    setData('');
    setOpen(false);
  };

  return (
    <div className={styles.container}>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <div>
          <h1 className={styles.title}>
            Welcome to <a href="https://nextjs.org">Strapi Recipe!</a>
          </h1>
        </div>
        <div className={styles.recipeContainer}>
          {!loading && recipes ? (
            recipes.map((recipe, i) => {
              const { title, description } = recipe.attributes;
              return (
                <div
                  onClick={() => handleCardClick(recipe.attributes)}
                  key={i}
                  className={styles.item}
                >
                  <RecipeCard
                    title={title}
                    bordered={true}
                    content={description}
                  />
                </div>
              );
            })
          ) : (
            <p>...Loading</p>
          )}
        </div>
        <RecipeModal
          title={recipe?.title}
          open={open}
          handleCloseModal={handleCloseModal}
          description={recipe?.description}
          ingredients={recipe?.ingredients}
          instructions={recipe?.instructions}
          getRecipeGPTData={fetchData}
          recipeGPTData={data}
          isLoading={isLoading}
        />
      </main>

      <footer className={styles.footer}>
        <a
          href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          Powered by Strapi
        </a>
      </footer>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Lastly, change the code in RecipeModal to display a button to query the API and a section for the plant-based version of the dish. The RecipeModal should now look like the one below:

import { Modal } from 'antd';

export default function RecipeModal({
  title,
  open,
  handleCloseModal,
  width = 1000,
  description,
  ingredients,
  instructions,
  getRecipeGPTData,
  recipeGPTData,
  isLoading,
}) {
  return (
    <div>
      <Modal
        title={title}
        centered
        open={open}
        onOk={handleCloseModal}
        onCancel={handleCloseModal}
        width={width}
      >
        <h3>Description</h3>
        <p>{description}</p>
        <h3>Ingredients</h3>
        <p>{ingredients}</p>
        {!isLoading ? (
          <button onClick={() => getRecipeGPTData(ingredients)}>
            Make plant based
          </button>
        ) : (
          <p>Loading...</p>
        )}

        <h3>Instructions</h3>
        <p>{instructions}</p>
        {recipeGPTData ? (
          <div>
            <h3>Plant based version</h3>
            <p>{recipeGPTData}</p>
          </div>
        ) : null}
      </Modal>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We should now be able to press the newly added button to generate a plant based version of the dish with our GPT powered API.

013-add-ai-functionality.png

Conclusion

So now we have seen how to set up a Strapi API to serve content to our frontend (in our case, Next.js, a React.Js framework). We have also shown how to set up custom API endpoints and connect them to other technologies, such as ChatGPT. This can be used to make our apps appealing to a wider audience by customizing the functionality.

Hopefully, this has inspired us to explore this area further and think of new ways to improve our apps.

Resources

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .