Build a local chatbot with React, openAI, and indexed DB (using Dexie.js)
Everybody wants to use AI, and I’m sure many clients will ask you to integrate a chat or agent into their products.
Quick overview
With the rise of large language models (LLMs) like OpenAI, Gemini, and DeepSeek, many companies are looking to integrate chatbots into their apps not just as support tools, but as intelligent advisors or agents.
These chatbots can offer personalized, value-added services by giving the LLM context from the app itself, making conversations more relevant and useful to the user.
In this post, we will create a chatbot using React and IndexedDB. We will use a wrapper called Dexie.js to help us implement our local database for storing information persistently..
However, before we proceed, we need to understand what IndexedDB is.
IndexedDB db is basically a low-level database api client to store large volumes of data on the browser, including files and blogs (images)
One of the main benefits is that it has non-blocking operation, all transactions are async compared with localStorage
Here is a comparison table between using localStorage and IndexedDB.
Feature | LocalStorage | IndexedDB |
---|---|---|
Data Type | Strings | Objects (JSON, files, blobs) |
Capacity | ~5MB | Hundreds of MB or more, potentially up to 50% of disk space |
API Complexity | Simple (synchronous, few methods) | Complex (asynchronous, transactions, multiple objects) |
Dexie.js
It is a JavaScript library which, at the same time, is a wrapper around IndexedDB the main benefit of using it is that it provides a practical and more intuitive api, and it abstracts a lot of the complexity of the native IndexedDB API, making async operations and error handling easier
Stack overview of the project
-
React
-
open Ai library
-
dexie.js
-
Vite + tailwind
This is the app we will build on this post
As we can see, all the questions and answers are stored in the browser using IndexedDB.
We will also need to create a .env
file in our app to set our OpenAPI key.
VITE_OPENAI_API_KEY='your open api key'
Remember to add this file to your .gitignore
file in order not to push this code to the repo
Never expose your OpenAI key in production builds. Utilize environment variables.
Setting up our local database
// db.ts
import Dexie, { type EntityTable } from 'dexie';
export interface ChatMessage {
id?: number;
question: string;
answer: string;
createdAt?: Date;
}
const db = new Dexie('chatDatabase') as Dexie & {
chatMessages: EntityTable<ChatMessage, 'id'>; // type and primary key
};
db.version(1).stores({
// ++ means autoincrement primary key
chatMessages: '++id, question, answer, createdAt',
});
export { db };
Now we will proceed with the app code.
We will need some variables to save messages that come from our db, the questions
, search
and isLoading
state.
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [question, setQuestion] = useState('');
const [search, setSearch] = useState('');
const [isLoading, setIsLoading] = useState(false);
We define a function called getMessages
to fetch all saved chat messages from the local IndexedDB using Dexie. Then, we call this function inside a useEffect
to load the messages once the component mounts
const getMessages = async () => {
const messages = await db.chatMessages.toArray(); // fetch all messages from the "chatMessages" table
setMessages(messages); // store them in local state to render
};
useEffect(() => {
getMessages(); // runs once when the component is mounted
}, []);
This is the code to render the app
return (
<div className="container mx-auto">
<h1 className="text-3xl font-bold text-center mt-5">Open AI chat</h1>
<div className="flex flex-col items-center justify-center h-screen">
<div className="w-full max-w-md">
<input
type="text"
placeholder="find a question"
className="w-full p-2 border border-gray-300 rounded-md mt-2"
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex flex-col h-[60vh] overflow-y-auto bg-gray-100 p-2 mt-2 rounded-md">
{renderMessages()}
</div>
<div className="mb-4">
<input
type="text"
placeholder="Enter your message"
className="w-full p-2 border border-gray-300 rounded-md mt-2"
onChange={(e) => setQuestion(e.target.value)}
/>
<button
disabled={isLoading || !question}
onClick={handleSend}
className="w-full bg-blue-500 text-white p-2 rounded-md mt-2"
>
{isLoading ? 'Loading...' : 'Send'}
</button>
</div>
</div>
</div>
</div>
);
Inside this code we have a function called renderMessages
The renderMessage
function displays messages when we type a term in the search input or when messages come from the getMessages
function, as shown in the following code block.
const renderMessages = () => {
const messagesToShow = search ? filteredMessages : messages;
if (messagesToShow.length === 0) {
return (
<div className="text-center text-gray-500 mt-4">
{search
? 'No messages found matching your search.'
: 'No messages yet. Start a conversation!'}
</div>
);
}
return messagesToShow.map((message) => (
<MessageItem
key={message.id}
message={message}
showDelete={!search} // Only show delete button for regular view, not search results
/>
));
};
This is the code for MessageItem
component
const MessageItem = ({
message,
showDelete = true,
}: {
message: ChatMessage;
showDelete?: boolean;
}) => (
<div className="mt-2 flex flex-col p-2 bg-gray-200 rounded-md">
<div className="flex justify-between items-center">
<p className="text-left font-bold">{message.question}</p>
{showDelete && (
<FaTrash
className="text-red-500 cursor-pointer"
onClick={() => handleDelete(message.id)}
/>
)}
</div>
<p className="text-left">{message.answer}</p>
</div>
);
This is the handleSend
function. It retrieves the question response from the OpenAI API. For more details, check the OpenAI documentation.
We use the safeAwait
function for error handling. If you want to learn more, read my other post where I explain how it works:
const handleSend = async () => {
setIsLoading(true);
const [response, err] = await safeAwait(
fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'o4-mini',
messages: [{ role: 'user', content: question }],
}),
})
);
if (err || !response?.ok) {
console.error(err);
return;
}
const data = await response?.json();
// once get the response it gets saved to our local db
await addMessage(question, data.choices[0].message.content || '');
setIsLoading(false);
setQuestion('');
};
This is the addMessage
function
const addMessage = async (question: string, answer: string) => {
const [, err] = await safeAwait(
db.chatMessages.add({
question,
answer,
createdAt: new Date(),
})
);
if (err) {
console.error(err);
return;
}
getMessages();
};
Function to delete a question
const handleDelete = async (id: number | undefined) => {
if (id === undefined) {
console.error('Cannot delete message: ID is undefined');
return;
}
const [, err] = await safeAwait(db.chatMessages.delete(id));
if (err) {
console.error('Failed to delete message:', err);
// You could add user feedback here, like a toast notification
return;
}
// Refresh the messages list after successful deletion
await getMessages();
};
And when the user is searching for a specific question
const filteredMessages = messages.filter((message) =>
message.question.toLowerCase().includes(search.toLowerCase())
);
This is the complete app code
import { useEffect, useState } from 'react';
import { db, type ChatMessage } from './db';
import { safeAwait } from './utils/safeAwait';
import { FaTrash } from 'react-icons/fa';
function App() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [question, setQuestion] = useState('');
const [search, setSearch] = useState('');
const [isLoading, setIsLoading] = useState(false);
const getMessages = async () => {
const messages = await db.chatMessages.toArray();
setMessages(messages);
};
const addMessage = async (question: string, answer: string) => {
const [, err] = await safeAwait(
db.chatMessages.add({
question,
answer,
createdAt: new Date(),
})
);
if (err) {
console.error(err);
return;
}
getMessages();
};
const handleSend = async () => {
setIsLoading(true);
const [response, err] = await safeAwait(
fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'o4-mini',
messages: [{ role: 'user', content: question }],
}),
})
);
if (err || !response?.ok) {
console.error(err);
return;
}
const data = await response?.json();
await addMessage(question, data.choices[0].message.content || '');
setIsLoading(false);
setQuestion('');
};
const handleDelete = async (id: number | undefined) => {
if (id === undefined) {
console.error('Cannot delete message: ID is undefined');
return;
}
const [, err] = await safeAwait(db.chatMessages.delete(id));
if (err) {
console.error('Failed to delete message:', err);
// You could add user feedback here, like a toast notification
return;
}
// Refresh the messages list after successful deletion
await getMessages();
};
useEffect(() => {
getMessages();
}, []);
const filteredMessages = messages.filter((message) =>
message.question.toLowerCase().includes(search.toLowerCase())
);
const MessageItem = ({
message,
showDelete = true,
}: {
message: ChatMessage;
showDelete?: boolean;
}) => (
<div className="mt-2 flex flex-col p-2 bg-gray-200 rounded-md">
<div className="flex justify-between items-center">
<p className="text-left font-bold">{message.question}</p>
{showDelete && (
<FaTrash
className="text-red-500 cursor-pointer"
onClick={() => handleDelete(message.id)}
/>
)}
</div>
<p className="text-left">{message.answer}</p>
</div>
);
const renderMessages = () => {
const messagesToShow = search ? filteredMessages : messages;
if (messagesToShow.length === 0) {
return (
<div className="text-center text-gray-500 mt-4">
{search
? 'No messages found matching your search.'
: 'No messages yet. Start a conversation!'}
</div>
);
}
return messagesToShow.map((message) => (
<MessageItem
key={message.id}
message={message}
showDelete={!search} // Only show delete button for regular view, not search results
/>
));
};
return (
<div className="container mx-auto">
<h1 className="text-3xl font-bold text-center mt-5">Open AI chat</h1>
<div className="flex flex-col items-center justify-center h-screen">
<div className="w-full max-w-md">
<input
type="text"
placeholder="find a question"
className="w-full p-2 border border-gray-300 rounded-md mt-2"
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex flex-col h-[60vh] overflow-y-auto bg-gray-100 p-2 mt-2 rounded-md">
{renderMessages()}
</div>
<div className="mb-4">
<input
type="text"
placeholder="Enter your message"
className="w-full p-2 border border-gray-300 rounded-md mt-2"
onChange={(e) => setQuestion(e.target.value)}
/>
<button
disabled={isLoading || !question}
onClick={handleSend}
className="w-full bg-blue-500 text-white p-2 rounded-md mt-2"
>
{isLoading ? 'Loading...' : 'Send'}
</button>
</div>
</div>
</div>
</div>
);
}
export default App;
Conclusion
With this setup, you have a solid starting point to create your chatbot. You now have a React app that:
-
Connects to the OpenAI API
-
Stores chat history locally using IndexedDB (via Dexie.js)
-
Supports search and deletion for better UX
-
Uses clean async error handling with a reusable wrapper
Want to extend it? try:
-
Adding chat roles (system/user/assistant)
-
Exporting chat history
Check out the full code on GitHub
https://github.com/angeldavid218/ai-chat
Thanks for reading, and see you in the next one!