NPK Media Logo

How to build a ChatGPT clone - Part One

In this multi-part guide, we provide the steps of how to build a ChatGPT clone with Next.js, TypeScript, React Context API, Tailwindcss and the Open AI “Text Completion” API.

Introduction

When following this guide, be advised that we have not included necessary security requirements such as rate-limiting for a production-ready application.

For part one, you'll need to have experience in Next.js and Tailwindcss. For part 2 onwards, you'll need to be proficient in React hooks, the Context api, making fetch requests, and other things.

The guide is split into multiple sections:

  • Part 1: Developing the UI for the ChatGPT clone (just one page with basic component structures and some styling)
  • Part 2: Developing the functionality of the application along with API routes
  • Part 3: Connecting the application to use a live database
  • Part 4: TBD

Building the frontend UI of the ChatGPT website clone

Initialising the project

In an empty Next.js project, initialise typescript with “touch tsconfig.json” (or just create a file in the root directory called tsconfig.json) and then run “yarn dev” to get all the TypeScript files installed. Once done, install the following packages:

yarn add -D tailwindcss autoprefixer @tailwindcss/line-clamp

yarn add sass react-icons


You'll now need to initialise tailwind with the following command:

npx tailwindcss init -p

Rename globals.css to globals.scss, and paste the following at the top of the file (don’t forget to rename the import of the stylesheet in _app.tsx if you don’t have auto-imports on):

@import'tailwindcss/base';
@import'tailwindcss/components';
@import'tailwindcss/utilities';

Inside tailwind.config.js we’ll add the pages and components directories for tailwind to target and also add the base styles for the application, your file should look like the following:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {
      backgroundColor: {
        gptXDarkGrey: '#202123',
        gptDarkGrey: '#444654',
        gptGrey: '#343541' 
      },
      textColor: {
        gptXLight: 'rgba(255, 255, 255, 0.9)',
        gptLight: 'rgba(255, 255, 255, 0.7)'
      }
    },
  },
  plugins: [
    require('@tailwindcss/line-clamp')
  ],
}

Creating the layout and components

With the following components, they should all appear directly under the /components directory:

Button.tsx

This is the global button and contains three variants; default, bordered and menu.

The prop “handler” isn’t used in this part 1 of the guide, however we’ll set it up now for later.

import { ReactNode, useMemo } from 'react';

interface IButton {
	children: ReactNode;
	variant?: 'menu' | 'bordered' | 'default';
	handler?: () => void;
}

export const Button = ({ children, variant = 'default', handler }: IButton) => {
	const variantStyles = useMemo(() => {
		switch (variant) {
			case 'default':
				return 'border-transparent text-gptXLight';
			case 'bordered':
				return 'border-white/20 text-gptXLight';
			case 'menu':
				return 'w-fit border-transparent';
		}
	}, [variant]);

	return (
		<button
			className={`w-full rounded-md border-2 p-3 text-sm ${variantStyles}`}
			onClick={() => (handler ? handler() : null)}>
			<span className='flex gap-3 items-center'>{children}</span>
		</button>
	);
};

Conversation.tsx

The conversation component will be rendered in the main body of the application when either 1. A conversation is initialised from sending the first message, or 2. When a conversationHistory item is clicked on from the sidebar menu.

import { demo_singleConversationData } from '../config/demoData';
import { Message } from './Message';

export const Conversation = () => {
	const messages = demo_singleConversationData.messages;

	return (
		<div>
			{messages &&
				messages.length > 0 &&
				messages.map(({ text, type, id }) => (
					<Message text={text} type={type} id={id} key={id} />
				))}
		</div>
	);
};

ConversationHistory.tsx

This will render all past conversations into the sidebar.

import { BsChatLeft } from 'react-icons/bs';
import { FiTrash2 } from 'react-icons/fi';
import { demo_conversationHistory } from '../config/demoData';

export const ConversationHistory = () => {
	const conversationHistory = demo_conversationHistory;

	return (
		<div className='flex flex-col gap-3'>
			{!conversationHistory ? (
				<p className='text-gptLight'>No conversation history</p>
			) : (
				conversationHistory.map(({ title, id }, index) => (
					<div
						key={id}
						className='border-2 border-transparent rounded-md px-3 py-1 hover:border-white/20 hover:cursor-pointer'>
						<div className='flex flex-row gap-3 items-center relative text-gptLight text-sm'>
							<BsChatLeft />
							<p className='line-clamp-1 mr-6'>{title}</p>
							<a className='absolute right-0 top-0 bottom-0 flex items-center hover:cursor-pointer'>
								<FiTrash2 />
							</a>
						</div>
					</div>
				))
			)}
		</div>
	);
};

Input.tsx

The component at the bottom of the page for handling the questions.

import { FiSend } from 'react-icons/fi';
import { Button } from './Button';

export const Input = () => {
	return (
		<div className='fixed md:relative bottom-0 right-0 left-0 w-full px-3 py-6'>
			<div className=''>
				<div className='w-full max-w-[800px] mx-auto flex relative text-gptLight'>
					<textarea
						placeholder='Enter something..'
						rows={1}
						className='w-full px-4 py-3 rounded-md bg-gptDarkGrey shadow-[0_0_15px_rgba(0,0,0,0.2)] resize-none'
					/>
					<div className='absolute right-0 top-0 bottom-0 flex items-center'>
						<Button>
							<FiSend className='rotate-45' color='rgba(255, 255, 255, 0.9)' />
						</Button>
					</div>
				</div>
			</div>
		</div>
	);
};

Message.tsx

This component will render in the main body of the application and it’s style will be determined by the message type: “question” or “response”.

export type IMessage = {
	id: string;
	type: 'question' | 'resposne';
	text: string;
};

export const Message = ({ text, type, id }: IMessage) => {
	return (
		<div
			className={`w-full text-gptLight p-6 ${
				type === 'question' ? 'bg-gptDarkGrey' : 'bg-gptGrey'
			}`}>
			<div className='max-w-[800px] mx-auto flex gap-12'>
				<div>
					<p>{type === 'question' ? 'Q' : 'A'}</p>
				</div>
				<p>{text}</p>
			</div>
		</div>
	);
};

Layout.tsx

A wrapper component which will take the Nav component, Sidebar component and children.

import { ReactNode } from 'react';
import { Nav } from './Nav';
import { Sidebar } from './Sidebar';

interface ILayout {
	children: ReactNode;
}

export const Layout = ({ children }: ILayout) => {
	return (
		<div className='flex flex-col md:flex-row h-full overflow-hidden'>
			<Nav />
			<Sidebar />
			<main className='bg-gptDarkGrey w-full h-full pb-2 relative'>
				{children}
			</main>
		</div>
	);
};

Nav.tsx

The Nav component will only appear on screen sizes below 768px and will have buttons for 1. Toggling the Sidebar component and 2. Starting a new conversation.

import { FiMenu, FiPlus } from 'react-icons/fi';
import { Button } from './Button';

export const Nav = () => {
	return (
		<nav className='bg-gptXDarkGrey text-gptLight text-center flex items-center w-full justify-between px-2 md:hidden'>
			<div>
        <Button variant='menu'>
          <FiMenu />
        </Button>
      </div>
			<h1>My Chat GPT Clone</h1>
			<div>
        <Button variant='menu'>
          <FiPlus />
        </Button>
      </div>
		</nav>
	);
};

Sidebar.tsx

This component will always be visible on desktop but only visible by toggling on screen sizes under 768px. It will contain the button for initialising new chats, conversation history items and secondary links.

import { Button } from './Button';
import { FiPlus, FiTrash2 } from 'react-icons/fi';
import { ConversationHistory } from './ConversationHistory';

export const Sidebar = () => {
	return (
		<div className='bg-gptXDarkGrey w-[320px] hidden md:block'>
			<nav className='p-2 flex flex-col h-[100vh] gap-3'>
				<Button variant='bordered'>
					<FiPlus /> New Chat
				</Button>

				<div className='flex flex-col flex-1 overflow-y-auto'>
					<ConversationHistory />
				</div>

				<div className='border-t-2 border-white/20'>
					<Button>
						<FiTrash2 /> Clear conversations
					</Button>
				</div>
			</nav>
		</div>
	);
};

Inside the pages directory, we’ll need to make a couple of adjustments to _app.tsx and index.tsx:

_app.tsx

Inside _app.tsx, we’ll import the Layout component and wrap the entire application with this component.

import { Layout } from '../components/Layout'
import '../styles/globals.scss'

export default function ChatGPTClone({ Component, pageProps }) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  )
};

Index.tsx

Inside index.ts, we’ll import the Conversation and Input components.

import { Conversation } from '../components/Conversation';
import { Input } from '../components/Input';

export default function Home() {
	const isActiveChat = false;

	return (
		<div className='h-[100vh] flex flex-col overflow-y-auto relative'>
			<div className='flex-1'>
				{!isActiveChat && (
					<div className='flex h-full justify-center items-center'>
						<h1 className='text-gptXLight'>NPK Media Chat GPT Clone</h1>
					</div>
				)}
				{isActiveChat && <Conversation />}
			</div>

			<Input />
		</div>
	);
};

Part one complete! That's it for building the basis of the UI out. We may tweak it later on when adding functionality into the application, such as a loading state, error messages etc.

Get notified for part 2

Want to hear of when we release the next part for building out the functionality of the ChatGPT clone? Subscribe to our mailing list to be one of the first to know.

Signup to our mailing list

Signup for social media updates, best marketing practices and the latest industry trends to grow your business'