👋 Recap from Previous Post
In the last part, we enhanced our chatbot gallery by adding a new feature to add new chatbot to the gallery:
✅ A working button that opens the modal when clicked.
✅ A complete modal layout with header, form fields, and action buttons.
✅ Form state handling using react-hook-form.
✅ Schema-based validation with zod and zodResolver.
Integrating the API
🎯 Goals for This Post
In this tutorial, we’ll integrate our app with the API so the Add Chatbot button actually creates a new chatbot in the gallery. The mock data currently used in the gallery will be replaced with real data from the API. This means that whenever you add a new chatbot, it will appear instantly in the gallery.
We’ll also introduce a new /services
folder in the repository to store all API request functions, and use react-query
to manage their state.
By the end of this tutorial, you’ll have a fully functional chatbot gallery that can display your chatbots and let you add new ones seamlessly.
📱 Step by Step Guide
1. Create /services
folder
First, let’s create a /services
folder under /src
, so the path becomes /src/services
. This folder will contain all the files for storing our API request functions.
We’ll start by creating a new file called HttpClient.ts
inside /src/services
. This file will define an Axios-based object that we’ll use to manage all HTTP communication with the API. It will also handle tasks such as error responses, interceptors, and request/response schema validation.
The first thing we’ll add is objects related to error handling:
import { ZodError } from "zod/v4";
/**
* Custom error class for HTTP client errors
*/
class HttpClientError extends Error {
constructor(
public readonly message: string,
public readonly status?: number,
public readonly data?: unknown
) {
super(message);
this.name = "HttpClientError";
}
}
/**
* Custom error class for validation errors
*/
class ValidationError extends Error {
constructor(public readonly errors: ZodError) {
super("Validation failed");
this.name = "ValidationError";
}
}
In the code above, we’ve created two new classes:
- One for handling general errors that may occur whenever we send a request.
- Another for handling schema validation errors, which will be triggered if the request body or response body doesn’t match with the expected schema.
Next, we’ll add a new type that defines the request configuration:
import { ZodError, ZodSchema } from "zod/v4";
import { AxiosRequestConfig } from "axios";
// ..... rest of the previous code
/**
* Extended Axios request configuration with additional options
*/
interface RequestConfig extends AxiosRequestConfig {
/**
* Schema for validating the response data
*/
responseSchema?: ZodSchema;
/**
* Whether to throw errors (true) or return them as rejected promises (false)
* @default true
*/
throwError?: boolean;
}
In the previous step, we created a new type that extends AxiosRequestConfig
and added a few extra properties to it:
-
responseSchema
– Allows the user to provide a schema for validating the API response. -
throwError
– Determines whether an error should be thrown if the API response doesn’t match the provided schema.
Now, let’s add the main part of this file: the HttpClient
object. This is the object we’ll use for all API requests moving forward.
// ... previous codes above
class HttpClient {
private baseUrl: string;
protected client: AxiosInstance;
/**
* Creates an instance of HttpClient
* @param {CreateAxiosDefaults} [options] - Axios configuration options
*/
constructor(options?: CreateAxiosDefaults) {
this.client = axios.create(options);
this.baseUrl = API_URL ?? options?.baseURL ?? "";
this.initializeRequestInterceptor();
this.initializeResponseInterceptor();
}
}
export default HttpClient;
Inside the HttpClient
, we define a few key variables:
-
baseUrl
– The URL of the API endpoints we’re calling. Normally, this points to the backend endpoint, but in our case, since we’ll be calling the third-party API directly, it will be set to that API’s URL. -
client
– The Axios instance that we’ll use for making HTTP requests.
You might notice a few missing references, such as initializeRequestInterceptor
, initializeResponseInterceptor
, and API_URL
. We’ll add these later in this post.
For API_URL
, we can create a file at /src/configs/env.ts
. This file will simply retrieve environment variables from .env
.
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? '';
Make sure to update your .env
file accordingly by adding this in your .env
:
NEXT_PUBLIC_API_URL=<your-api-url>
we will not add the interceptors to the HttpClient
class:
class HttpClient {
// ... previous codes
/**
* Initialize request interceptor
*/
protected initializeRequestInterceptor(): void {
this.client.interceptors.request.use(
(config) => {
// Ensure headers exist
config.headers = config.headers || {};
// Add security headers
config.headers['x-rapidapi-key'] = X_RAPID_API_KEY;
config.headers['x-rapidapi-host'] = X_RAPID_API_HOST;
config.headers['Authorization'] = 'Bearer ' + API_KEY;
config.headers['Content-Type'] = 'application/json';
return config;
},
(error) => {
// Handle request error
return Promise.reject(error);
}
);
}
/**
* Initialize response interceptor
*/
protected initializeResponseInterceptor(): void {
this.client.interceptors.response.use(
(response) => response?.data,
(error: AxiosError) => {
// Handle axios errors
if (error.response) {
const { status, data } = error.response;
const message =
typeof data === 'object' && data !== null && 'message' in data
? String(data.message)
: error.message;
const clientError = new HttpClientError(message, status, data);
// Check if we should throw the error or return it as a rejected promise
// Cast config to our custom RequestConfig type
const config = error.config as unknown as RequestConfig;
const throwError = config?.throwError !== false;
if (throwError) {
throw clientError;
}
return Promise.reject(clientError);
}
return Promise.reject(error);
}
);
}
}
the new codes we added should resolve the previous errors, we added the interceptors:
initializeRequestInterceptor
this interceptor will be triggered each time we call request to the api. we are using this to add headers to each of the api call. a few modifications that you could make here are, if not all of the api request you called needed authorization or any other keys, then you can delete this part and or create a new HttpClient for api endpoints that doesn’t need a key.initializeResponseInterceptor
this interceptor will be triggered each time we get a response from the api. we are using this to handle any error that might come from the api.
For X_RAPID_API_KEY
, X_RAPID_API_HOST
, and API_KEY
, you can follow the same process we used for API_URL
:
- Add them to
/src/configs/env.ts
- Add them to your
.env
file
NEXT_PUBLIC_API_URL=YOUR_API_URL
NEXT_PUBLIC_X_RAPID_API_KEY=YOUR_X_RAPID_API_KEY
NEXT_PUBLIC_X_RAPID_API_HOST=YOUR_X_RAPID_API_HOST
NEXT_PUBLIC_API_KEY=YOUR_API_KEY
Next, we’ll add two private helper functions to HttpClient
that will be used by the request methods we create later.
class HttpClient {
// ... previous codes
/**
* Create a full URL from a path
* @param {string} path - The path to append to the base URL
* @returns {string} The full URL
*/
private createUrl(path: string): string {
return this.baseUrl + path;
}
/**
* Parse and validate response using Zod schema
* @param {T} response - The response to validate
* @param {ZodSchema} schema - The schema to validate against
* @returns {T} The validated response
* @throws {ValidationError} If validation fails
*/
private parseResponse<T>(response: T, schema: ZodSchema): T {
const parsed = schema.safeParse(response);
if (!parsed.success) {
throw new ValidationError(parsed.error);
}
return response;
}
}
These functions are:
createUrl
Combines thebaseUrl
with the specific endpoint path to produce the full request URL.parseResponse
Validates the response data from the API against the schema provided by the user (if any).
The final part of HttpClient
is the modified HTTP methods (GET, POST, PUT, PATCH, DELETE).
class HttpClient {
// ... previous codes
/**
* Make a GET request
* @param {string} path - The path to request
* @param {RequestConfig} [config] - The request configuration
* @returns {Promise<T>} The response data
*/
async get<T>(path: string, config?: RequestConfig): Promise<T> {
const url = this.createUrl(path);
const response: T = await this.client.get(url, config);
if (config?.responseSchema) {
return this.parseResponse(response, config.responseSchema);
}
return response;
}
/**
* Make a POST request
* @param {string} path - The path to request
* @param {unknown} [data] - The data to send
* @param {RequestConfig} [config] - The request configuration
* @returns {Promise<T>} The response data
*/
async post<T>(
path: string,
data?: unknown,
config?: RequestConfig
): Promise<T> {
const url = this.createUrl(path);
const response: T = await this.client.post(url, data, config);
if (config?.responseSchema) {
return this.parseResponse(response, config.responseSchema);
}
return response;
}
/**
* Make a PUT request
* @param {string} path - The path to request
* @param {unknown} [data] - The data to send
* @param {RequestConfig} [config] - The request configuration
* @returns {Promise<T>} The response data
*/
async put<T>(
path: string,
data?: unknown,
config?: RequestConfig
): Promise<T> {
const url = this.createUrl(path);
const response: T = await this.client.put(url, data, config);
if (config?.responseSchema) {
return this.parseResponse(response, config.responseSchema);
}
return response;
}
/**
* Make a PATCH request
* @param {string} path - The path to request
* @param {unknown} [data] - The data to send
* @param {RequestConfig} [config] - The request configuration
* @returns {Promise<T>} The response data
*/
async patch<T>(
path: string,
data?: unknown,
config?: RequestConfig
): Promise<T> {
const url = this.createUrl(path);
const response: T = await this.client.patch(url, data, config);
if (config?.responseSchema) {
return this.parseResponse(response, config.responseSchema);
}
return response;
}
/**
* Make a DELETE request
* @param {string} path - The path to request
* @param {RequestConfig} [config] - The request configuration
* @returns {Promise<T>} The response data
*/
async delete<T>(path: string, config?: RequestConfig): Promise<T> {
const url = this.createUrl(path);
const response: T = await this.client.delete(url, config);
if (config?.responseSchema) {
return this.parseResponse(response, config.responseSchema);
}
return response;
}
}
Each of these methods:
- Merges the
baseUrl
with the provided endpoint path to form the complete URL - Sends the API request using
Axios
- Validates the response against the provided schema, if one is given
Now that HttpClient
is complete, we can move on to creating our API endpoint services.
We’ll start with PersonaService
. this file will contain all API request functions related to:
- Creating a new chatbot
- Getting a specific chatbot’s information
- Fetching the full list of chatbots
Create a new file at /src/services/PersonaService.ts
.
import {
Persona,
PersonaGetAllResponse,
PersonaPostResponse,
} from "@/types/persona";
import HttpClient from "./HttpClient";
interface PersonaServiceType {
getAllPersona: () => Promise<PersonaGetAllResponse>;
addPersona: (payload: Persona) => Promise<PersonaPostResponse>;
}
class PersonaService implements PersonaServiceType {
private _http: HttpClient;
constructor(httpClient: HttpClient) {
this._http = httpClient;
}
getAllPersona: PersonaServiceType["getAllPersona"] = () =>
this._http.get("/persona");
addPersona: PersonaServiceType["addPersona"] = (payload) =>
this._http.post("/persona", payload);
}
export default PersonaService;
In the code above, we define a class called PersonaService
, which provides methods such as getAllPersona
and addPersona
for interacting with the API. These methods make use of the HttpClient
we built earlier to perform HTTP requests.
You’ll notice that this file references several types that don’t exist yet. We’ll create them in the next step.
First, we’ll define the schemas using zod
, and from those schemas, we’ll generate the corresponding TypeScript types for persona.
Step 1 . Create the schema
File path: /src/schema/persona.ts
import z from "zod/v4";
import { timeResponseSchema } from "./general";
export const personaSchema = z.object({
name: z.string(),
personality: z.string().optional(),
tone_style: z.string().optional(),
interests: z.string().optional(),
behaviour: z.string().optional(),
age: z.number(),
gender: z.string(),
});
const personaResponseSchema = personaSchema.extend({
...timeResponseSchema.shape,
id: z.uuid(),
});
export const personaGetAllResponseSchema = z.object({
data: z.array(personaResponseSchema),
});
export const personaPostResponseSchema = z.object({
data: personaResponseSchema,
});
These schemas allow us to validate both request and response bodies, ensuring they match the API’s expected format.
Step 2. Create the types
File path: /src/types/persona.ts
import {
personaGetAllResponseSchema,
personaPostResponseSchema,
personaSchema,
} from '@/schema/persona';
import z from 'zod/v4';
export type Persona = z.infer<typeof personaSchema>;
export type PersonaGetAllResponse = z.infer<typeof personaGetAllResponseSchema>;
export type PersonaPostResponse = z.infer<typeof personaPostResponseSchema>;
export type PersonaGetResponse = z.infer<typeof personaPostResponseSchema>; // PersonaPostResponseSchema have similar response type.
In this file, you’ll notice that two of the types are based on the same schema. This is intentional since they currently share the same structure, we reference the same schema. However, having separate type names gives us flexibility to easily change them in the future if their definitions diverge.
With these types and schemas in place, all errors should now be resolved.
The final step is to expose HttpClient
and PersonaService
so they can be accessed throughout the project.
Create this new files for it /src/services/index.ts
:
import ChatService from './ChatService';
import HttpClient from './HttpClient';
import PersonaService from './PersonaService';
const httpClient = new HttpClient();
export const personaService = new PersonaService(httpClient);
2. Create /hooks
folder
Now that we’ve finished building the services in the previous step, we can move on to creating hooks.
The /src/hooks
folder will store all custom hooks in our project, whether they’re related to API calls or other functionalities. For now, we’ll focus on creating hooks to manage our PersonaService
that we built earlier.
Step 1. Create the hooks folder
Here’s the path: /src/hooks
Step 2. Create the Persona hooks
Here’s the path: src/hooks/persona.ts
. We’ll use react-query
to handle data fetching and mutations.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Persona,
PersonaGetAllResponse,
} from "@/types/persona";
import toast from "react-hot-toast";
import { personaService } from "@/services";
export const useCreatePersona = () => {
const queryClient = useQueryClient();
const { mutate, isPending, isError, error } = useMutation({
mutationFn: (payload: Persona) => personaService.addPersona(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["get", "persona"] });
toast.success("Persona created successfully");
},
onError: (error: Error) => {
console.error("Error creating persona:", error.message);
toast.error(`Failed to create persona: ${error.message}`);
},
});
return { mutate, isPending, isError, error };
};
export const useGetAllPersona = () => {
return useQuery<PersonaGetAllResponse, Error>({
queryKey: ["get", "all", "persona"],
queryFn: async () => await personaService.getAllPersona(),
});
};
The code above adds two hooks:
-
useCreatePersona
This hook is responsible for creating a new chatbot by calling theaddPersona
function fromPersonaService
.- We added a toast notification to give users visual feedback when their action (creating a chatbot) succeeds or fails.
- On a successful request, we call
invalidateQueries
with the keys["get", "persona"]
. This tells React Query to invalidate the existing cached data for that query, which triggers a refetch and updates our chatbot gallery with the newly created chatbot.
-
useGetAllPersona
This hook retrieves all chatbots by callinggetAllPersona
fromPersonaService
.- We use the query keys
["get", "all", "persona"]
to uniquely identify this query. - These keys allow us to selectively invalidate or refetch this specific data whenever needed.
- We use the query keys
You might notice another import in the code that throws an error. To fix this, install the required package:
pnpm add react-hot-toast
That’s it for creating hooks based on the service we built in the previous step.
In the next section, we’ll integrate these hooks into our UI components.
3. Integrate with GalleryPage
Before integrating the hooks into GalleryPage
, we first need to set up react-query
in our project.
Add a new file inside the /src/hooks
folder called reactQuery.tsx
. This file will be responsible for setting up the React Query Provider.
'use client';
import {
QueryClient,
QueryClientConfig,
QueryClientProvider,
} from '@tanstack/react-query';
import React, { useState } from 'react';
const QueryProvider = ({ children }: { children: React.ReactNode }) => {
const queryClientOptions: QueryClientConfig = {
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
},
};
const [queryClient] = useState(() => new QueryClient(queryClientOptions));
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
export default QueryProvider;
In the code above, we create a QueryClientProvider
component, which will wrap our application so that query states can be accessed by any component inside it.
Next step, open /src/app/layout.tsx
and import the QueryProvider
component. Wrap the <body>
element (and its children) with it.
import type { Metadata } from 'next';
import { Inter, Poppins, JetBrains_Mono } from 'next/font/google';
import './globals.css';
import React from 'react';
import QueryProvider from '@/hooks/reactQuery';
import { Toaster } from 'react-hot-toast';
const inter = Inter({
variable: '--font-inter',
subsets: ['latin'],
display: 'swap',
});
const poppins = Poppins({
variable: '--font-poppins',
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
display: 'swap',
});
const jetbrainsMono = JetBrains_Mono({
variable: '--font-jetbrains-mono',
subsets: ['latin'],
display: 'swap',
});
export const metadata: Metadata = {
title: 'Chatbot Gallery - AI Chatbot Hub',
description:
'Discover and interact with unique AI chatbots featuring distinct personas. Create, share, and explore endless roleplay possibilities.',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<QueryProvider>
<body
className={`${inter.variable} ${poppins.variable} ${jetbrainsMono.variable} antialiased`}
>
<Toaster position="top-center" />
{children}
</body>
</QueryProvider>
</html>
);
}
In addition to importing the QueryProvider
, I also included the fonts we’ll be using throughout the project.
If I haven’t mentioned it earlier, make sure to add the Toaster
component here as well. This component is required for displaying toast notifications whenever react-hot-toast
is triggered.
Now that the QueryProvider
is set up, we can move on to integrating our hooks into the GalleryPage
.
Integrate with GalleryPage
Now that we’ve built the hooks to call our APIs, we can start by fetching all the chatbots we’ve created and displaying them on the GalleryPage
. We’ll begin by working in /src/modules/gallery/index
.
const GalleryPage = ()=>{
const [searchTerm, setSearchTerm] = useState("");
const [selectedInterest, setSelectedInterest] = useState<string[]>([]);
const [selectedPersonality, setSelectedPersonality] = useState<string[]>([]);
const [isModalOpen, setModalOpen] = useState(false);
// ---------- Add These ----------
// Fetch chatbots data
const { data: personasResponse, isLoading, error } = useGetAllPersona();
const personas = personasResponse?.data || [];
// --------------------
const onCardClick = () => {
// Do nothing yet
};
// Get all unique interests
const allInterests = useMemo(() => {
const interests = new Set<string>();
personas.forEach((persona) => {
// Collect unique interests from personas
if (persona.interests) {
persona.interests.split(",").forEach((el) => interests.add(el.trim()));
}
});
return Array.from(interests).sort();
}, [personas]);
// ..... rest of the codes
}
From the code above, the first step is calling useGetAllPersona
to retrieve data
, isLoading
, and error
.
We assign an alias to data as personasResponse
for clarity. This is especially useful since we’ll be adding more hooks here in the future.
Next, we set personasResponse
to personas
and set it to default to an empty array in case the data is undefined
.
const GalleryPage = ()=>{
// ..... rest of the codes
// Get all unique interests
const allInterests = useMemo(() => {
const interests = new Set<string>();
personas.forEach((persona) => {
// Collect unique interests from personas
if (persona.interests) {
persona.interests.split(",").forEach((el) => interests.add(el.trim()));
}
});
return Array.from(interests).sort();
}, [personas]);
// Get all unique personality
const allPersonality = useMemo(() => {
const personalities = new Set<string>();
personas.forEach((persona) => {
// Collect unique interests from personas
if (persona.personality) {
persona.personality
.split(",")
.forEach((el) => personalities.add(el.trim()));
}
});
return Array.from(personalities).sort();
}, [personas]);
const filteredChatbots = useMemo(() => {
return personas.filter((persona) => {
const matchesSearch = persona.name
.toLowerCase()
.includes(searchTerm.toLowerCase());
const matchesInterests =
selectedInterest.length === 0 ||
selectedInterest.some((e) => persona.interests?.includes(e));
const matchesPersonalities =
selectedPersonality.length === 0 ||
selectedPersonality.some((e) => persona.personality?.includes(e));
return matchesSearch && matchesInterests && matchesPersonalities;
});
}, [personas, searchTerm, selectedInterest, selectedPersonality]);
// ..... rest of the codes
}
In the updated code above, all occurrences of chatbotsData
have been replaced with personas, along with related variables like allInterests
, allPersonality
, and filteredChatbots
.
The next step is to add a simple fallback render for when data is still loading or when an error occurs.
const GalleryPage = ()=>{
// ... rest of the codes
const handlePersonalityToggle = (tag: string) => {
setSelectedPersonality((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
// ----- Add These -----
const handlePersonaCreated = () => {
refetch();
};
if (isLoading) {
return (
<div className="flex justify-center items-center h-64" aria-live="polite">
<p className="text-sm text-gray-500">Loading personas...</p>
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-64" aria-live="polite">
<p className="text-sm text-red-500">
Error loading personas: {error.message}
</p>
</div>
);
}
// --------------------
return (
<div ....>
{/** rest of the codes */}
<AddChatbotModal
isOpen={isModalOpen}
onClose={() => setModalOpen(false)}
onSubmitSuccess={handlePersonaCreated}
/>
{/** rest of the codes */}
</div>
}
Here, we’ve added basic conditional rendering, if the query is still loading, it shows a loading state; if an error is caught, it shows an error state. Additionally, we created a new function that runs after the AddChatbotModal
finishes submission, which will automatically refetch the query to update the data.
At this point, if you run the project, you’ll notice that the chatbot cards are empty. This is expected since we haven’t created any chatbots yet (previously, the display relied on mock data).
In the next step, we’ll integrate the API into our Add Chatbot feature so we can start populating the gallery with actual chatbot data.
4. Integrate with Add Chatbot
Now onto the final part of this post, adding API calls when creating a new chatbot. We’ll start by adding useCreatePersona
to the file: /src/modules/gallery/components/AddChatbotModal.tsx
// .. earlier part of the codes
export const AddChatbotModal: React.FC<AddChatbotModalProps> = ({
isOpen,
onClose,
onSubmitSuccess,
}) => {
// Add this line
const { mutate: createPersona, isPending } = useCreatePersona();
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm({
resolver: zodResolver(personaSchema),
defaultValues: {
name: "",
personality: "",
tone_style: "",
interests: "",
behaviour: "",
age: 0,
gender: "",
},
});
const onFormSubmit = (data: {
name: string;
personality?: string;
tone_style?: string;
interests?: string;
behaviour?: string;
age: number;
gender: string;
}) => {
// Add this part as well
createPersona(data, {
onSuccess: () => {
reset();
onClose();
onSubmitSuccess?.();
},
});
};
// ... later part of the codes
From the code above, you can see that we’ve integrated useCreatePersona
into the component and called it inside onFormSubmit
. In the onSuccess
callback, we reset the form, close the modal, and call onSubmitSuccess
(if it’s defined).
Also, make sure to remove the previous isPending
variable, since useCreatePersona
already provides its own isPending
state.
At this point, your form should be fully functional, you can now create new chatbots freely and see them immediately displayed on the screen.
🚀 What’s Next?
In the next post, we’ll start working on a new page which is the Chat UI.
New posts will be released every 2–3 days, so make sure to subscribe or bookmark this page to stay updated!
Please check Part 7 here:
👉 (Part 7) Build a Simple Chat Character Gallery: Implementing the Chat Page UI
Top comments (0)