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
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
Once this is complete and it finishes installing the dependencies, we should receive a confirmation like the one below!
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:
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.
Click continue, and from here, click text as the data type like this:
Then enter the title and make sure short text is selected.
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:
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:
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.
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
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
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.
Now if we enter the following into our browser and click enter, we should get an "ok" message.
http://localhost:1337/api/recipe-gpt
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
Then in the .env
file add the following environment variable:
OPENAI=< OpenAI api key here>
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: [],
},
},
],
};
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);
}
},
};
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;
}
},
});
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"
}
}'
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
Navigate into this newly created directory and run the below
yarn dev
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:
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>
);
}
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
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>
);
}
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;
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>
);
}
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;
}
}
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 };
}
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>
);
}
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;
}
Now, if we reload the project and navigate to localhost
we should be able to see the recipe we added as shown below:
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>
);
}
Now if we navigate to localhost
and click on a recipe we should be able to see all of it's details.
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;
}
}
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 };
}
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>
);
}
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>
);
}
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.
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.