TL;DR
In this article, we will build a Next.js application that integrates with the Notion API to manage a user database and enables interaction with this data using CopilotKit.
By the end of the article, you will learn how to:
- Set up server actions to fetch a user’s Notion database.
- Implement a chat feature using CopilotKit to query the database.
- Use CopilotKit to edit a user’s Notion database directly within the chat.
Plus, we’ll explore ways to achieve type safety in environment variables for your Next.js project. 👀
What is CopilotKit
CopilotKit is the leading open-source framework for integrating production-ready AI-powered copilots into your applications. It provides a feature-rich SDK that supports various AI copilot use cases, including context awareness, copilot actions, and generative UIs.
This means you can focus on defining your copilot's role, rather than getting bogged down in the technicalities of building one from scratch or dealing with complex integrations.
Check out CopilotKit's GitHub ⭐️
Setting Up the Project 🛠️
First, Initialize a Next.js project with the following command:
ℹ️ You can use any package manager of your choice. Here, I’ll use npm.
npx create-next-app@latest copilotkit-with-notion-api --typescript --tailwind --eslint --app --use-npm
Navigate into the project directory:
cd copilotkit-with-notion-api
Installing Dependencies
We’ll need several dependencies. Run this command to install all the dependencies required for our project:
npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime @notionhq/client @t3-oss/env-nextjs openai zod
For an improved coding experience, install the following development dependencies:
npm i --save-dev prettier-plugin-organize-imports prettier-plugin-package prettier-plugin-tailwindcss
Configuring Prettier
Now that Prettier is installed, let’s configure it to match our preferences. Create a .prettierrc
file in the project root with the following settings:
// 👇 .prettierrc
{
"arrowParens": "avoid",
"printWidth": 80,
"semi": false,
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "all",
"proseWrap": "always",
"tabWidth": 2,
"plugins": [
"prettier-plugin-tailwindcss",
"prettier-plugin-organize-imports",
"prettier-plugin-package"
]
}
Feel free to adjust these rules to suit your preferences.
Setting Up Shadcn UI
For a collection of ready-to-use UI components, we’ll use shadcn/ui. Initialize it with the default settings by running:
npx shadcn@latest init -d
Adding Type-Safe Environment Variables
To manage environment variables, we’ll go beyond the typical .env
setup and implement type safety using TypeScript. This ensures that the app won’t run without all required variables properly defined.
We’ll use the @t3-oss/env-nextjs
library and the zod
schema validation library for this purpose.
Create a new file lib/env.ts
with the following code:
// 👇 lib/env.ts
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
/*
* Serverside Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
NOTION_SECRET_API_KEY: z.string().min(1),
NOTION_DB_ID: z.string().min(1),
OPENAI_API_KEY: z.string().min(1),
},
/*
* Environment variables available on the client (and server).
*
* 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
*/
client: {},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
*/
runtimeEnv: {
NOTION_SECRET_API_KEY: process.env.NOTION_SECRET_API_KEY,
NOTION_DB_ID: process.env.NOTION_DB_ID,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
},
})
This approach validates environment variables at runtime. If any required variable is missing or invalid, the app will simply fail to start.
As you might have guessed, all these env
variables must be defined in the .env
file. This includes the Notion API keys and, most importantly, the OpenAI API key.
For now, simply populate the .env
file with your OpenAI API Key:
OPENAI_API_KEY=<YOUR-OPENAI-API-KEY>
Setting up Notion 📔
To use the Notion API, we first need to set up a Notion integration.
Create a New Integration
Visit notion.so/my-integrations and create a new integration. Provide a name, select the workspace, and note the Internal Integration Secret.
Update the integration’s capabilities to include Update and Insert permissions.
Add the secret key to your .env
file as:
NOTION_SECRET_API_KEY=<YOUR-SECRET-HERE>
Set up a Database
In Notion, create a new database or use an existing one. For this tutorial, here’s a sample database with three columns: name
, link
, and dueDate
.
You can customize these columns to match your specific requirements.
Find your Database ID
To get the Notion Database ID, check the URL of your database. The ID is the string between the Notion domain name and the ?v=
query parameter.
Add this ID to your .env
file:
NOTION_DB_ID=<YOUR-DB-ID-HERE>
Assign Integration to the Database
Go to your database, click the menu button in the top-right corner, and assign the integration you created earlier.
With this setup complete, your application is ready to access and manage your Notion database using the Notion API. ✨
Setting up CopilotKit 🤖
So far so good, Now, let’s integrate CopilotKit — the soul of our application that enables interaction with the Notion database.
Define Constants
Start by creating a constants.ts
file in the lib/
directory to centralize constants related to your database structure and API endpoint:
// 👇 lib/constants.ts
export const NOTION_DB_PROPERTY_LINK = 'link'
export const NOTION_DB_PROPERTY_NAME = 'name'
export const NOTION_DB_PROPERTY_DUE_DATE = 'dueDate'
export const COPILOTKIT_API_ENDPOINT = '/api/copilotkit'
Update the database column names to match your setup. The COPILOTKIT_API_ENDPOINT
constant defines the endpoint for CopilotKit requests.
Create an API Route
Next, create a route.ts
file inside the /app/api/copilotkit
directory:
// 👇 app/api/copilotkit/route.ts
import { COPILOTKIT_API_ENDPOINT } from '@/lib/constants'
import { env } from '@/lib/env'
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from '@copilotkit/runtime'
import { NextRequest } from 'next/server'
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY })
// Here, we are using GPT-3.5-turbo OpenAI model instead of the default `gpt-4o`
const serviceAdapter = new OpenAIAdapter({ openai, model: 'gpt-3.5-turbo' })
const runtime = new CopilotRuntime()
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: COPILOTKIT_API_ENDPOINT,
})
return handleRequest(req)
}
This file sets up a POST
route for handling CopilotKit requests. It uses the OpenAI GPT-3.5-turbo
model and defines a runtime environment for processing requests.
The POST
function listens for requests, processes them using a custom runtime and service adapter, and then sends the response back through the CopilotKit endpoint for handling AI-generated responses.
Add CopilotKit Provider
To integrate CopilotKit into your application, wrap your app in the CopilotKit
provider. Also, include the pre-built Copilot popup for instant UI functionality.
Update layout.tsx
as follows:
// 👇 app/layout.tsx
import { COPILOTKIT_API_ENDPOINT } from '@/lib/constants'
import { CopilotKit } from '@copilotkit/react-core'
import { CopilotPopup } from '@copilotkit/react-ui'
import '@copilotkit/react-ui/styles.css'
// ...Rest of the code
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang='en'>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<CopilotKit runtimeUrl={COPILOTKIT_API_ENDPOINT}>
<main>{children}</main>
<CopilotPopup instructions='You are assisting the user as best as you can. Answer the best way possible given the user notion database information.' />
</CopilotKit>
</body>
</html>
)
}
First, we are simply importing the required modules and the custom styles that will be needed to make the Copilot popup window look good.
We wrap our application with the <CopilotKit />
provider and pass the runtimeUrl
to our COPILOTKIT_API_ENDPOINT
constant, which is the /api/copilotkit
endpoint.
By now, you should already see a small chat popup in the bottom right corner of your application. It should also already look good out of the box.
This is all we need to set up CopilotKit in our application. 🥂 Now, all that’s left is to provide the context of our application so that it can read our data and guide us in real time.
Work on the Implementation 👷♂️
Now, that all the before works is done, lit's time to implement the core functionality of our application.
We'll start by defining the data types and progressively build the functionality to fetch, and manipulate data from the Notion database.
Defining Types
Create a file types/notion.ts
in the types/
directory to define the structure of our Notion database data:
// 👇 types/notion.ts
import {
NOTION_DB_PROPERTY_DUE_DATE,
NOTION_DB_PROPERTY_LINK,
NOTION_DB_PROPERTY_NAME,
} from '@/lib/constants'
export type TResponse = {
success: boolean
error: Error | null
}
export type TRow = {
id: string
properties: {
[NOTION_DB_PROPERTY_NAME]: {
id: string
title: { text: { content: string } }[]
}
[NOTION_DB_PROPERTY_LINK]: { id: string; url: string }
[NOTION_DB_PROPERTY_DUE_DATE]: {
id: string
type: 'date'
date?: { start: string; end: string }
}
}
}
export type TRowDetails = {
id: string
[NOTION_DB_PROPERTY_NAME]: string
[NOTION_DB_PROPERTY_LINK]: string
[NOTION_DB_PROPERTY_DUE_DATE]: {
start: string
end: string
}
}
We firstly have the TResponse
type which we will use in our server actions to define the return type of a function. Then we have the TRow
and the TRowDetails
type which basically holds the type definition of the data in each row of the notion database.
The TRow
type is defined to match the returned data from the notion API. The TRowDetails
type is the custom type which I have made to hold only the data I plan to show in each row in the UI.
💡 Adjust the properties in these types according to your Notion database structure.
Fetching Data from Notion
Create lib/actions.ts
to define server-side actions for interacting with the Notion database:
// 👇 lib/actions.ts
'use server'
import { env } from '@/lib/env'
import { TResponse } from '@/types/notion'
import { Client } from '@notionhq/client'
import { QueryDatabaseResponse } from '@notionhq/client/build/src/api-endpoints'
const notion = new Client({
auth: env.NOTION_SECRET_API_KEY,
})
export const fetchNotionDB = async (): Promise<
QueryDatabaseResponse | TResponse
> => {
try {
const dbQuery = await notion.databases.query({
database_id: env.NOTION_DB_ID,
})
return dbQuery
} catch (error) {
return {
success: false,
error: error as Error,
} as TResponse
}
}
The fetchNotionDB
function is marked as a server action ('use server'
) to ensure it only runs on the server, regardless of where it’s called.
Firstly, we are creating a instance of notion client by passing our database id. Then in the fetchNotionDB
function, we are querying our notion database with the notion API and returning back the response.
Displaying Data
Now, let’s update the app/page.tsx
file to fetch and render the Notion database data:
// 👇 app/page.tsx
import { NotionTable } from '@/components/notion-table'
import { fetchNotionDB } from '@/lib/actions'
import {
NOTION_DB_PROPERTY_DUE_DATE,
NOTION_DB_PROPERTY_LINK,
NOTION_DB_PROPERTY_NAME,
} from '@/lib/constants'
import { isErrorResponse } from '@/lib/utils'
import { TRow } from '@/types/notion'
export default async function Home() {
const response = await fetchNotionDB()
if (isErrorResponse(response)) {
return (
<div className='mt-10 text-center text-rose-500'>
Failed to fetch data. Please try again later.
</div>
)
}
const dbRows = response.results.map(row => ({
id: row.id,
// @ts-expect-error properties field definitely exists in each row.
properties: row.properties || {},
})) as TRow[]
const formattedDBRows = dbRows.map(({ id, properties }) => {
const name =
properties?.[NOTION_DB_PROPERTY_NAME]?.title?.[0]?.text?.content || ''
const link = properties?.[NOTION_DB_PROPERTY_LINK]?.url || ''
const dueDate = properties?.[NOTION_DB_PROPERTY_DUE_DATE]?.date || {
start: '',
end: '',
}
return {
id,
[NOTION_DB_PROPERTY_NAME]: name,
[NOTION_DB_PROPERTY_LINK]: link,
[NOTION_DB_PROPERTY_DUE_DATE]: dueDate,
}
})
return (
<div className='mt-8 flex justify-center'>
<div className='w-full max-w-4xl'>
<NotionTable initialTableData={formattedDBRows} />
</div>
</div>
)
}
Here, we first fetch the user's Notion database information using the fetchNotionDB
function. Then, we check the type of the function's return data to ensure it is of the QueryDatabaseResponse
type. If it is not, we simply show an error message and return.
💡 The comment at the top of the properties assignment is to suppress the error that suggests a row might be of another type and may not have a properties field. However, querying a database always returns a properties field for each row.
Next, we fetch all the rows of the Notion database and cast them as TRow[]
. Since it contains some data that isn’t particularly relevant to us, we format it into the formattedDBRows
variable as TRowDetails[]
.
Finally, we pass this fetched data to the <NotionTable />
component, which handles displaying the table in the UI.
Error Handling Utility
Notice that we haven’t yet implemented the isErrorResponse
function. Add a utility function to determine if a response is an error. Update lib/utils.ts
as follows:
// 👇 lib/utils.ts
import { TResponse } from '@/types/notion'
import { QueryDatabaseResponse } from '@notionhq/client/build/src/api-endpoints'
// ...Rest of the code
export function isErrorResponse(
data: QueryDatabaseResponse | TResponse,
): data is TResponse {
if (
typeof data === 'object' &&
data !== null &&
'success' in data &&
typeof (data as TResponse).success === 'boolean'
) {
return (data as TResponse).success === false
}
return false
}
This function checks whether the data is of type TResponse
and if the success
field is set to false
. If yes, we return False
, else we return True
.
Adding and Configuring UI Components
Now, all we need to do is implement the <NotionTable />
component. Firstly before doing that, let’s add a few shadcn/ui components we will use.
Install required UI components:
npx shadcn@latest add sonner table
Enable the Sonner
toast notifications by adding its provider to app/layout.tsx
:
// 👇 app/layout.tsx
import { Toaster } from '@/components/ui/sonner'
// ...Rest of the code
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang='en'>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{/* ...Rest of the code */}
<main>{children}</main>
<Toaster />
{/* ...Rest of the code */}
</body>
</html>
)
}
Rendering the Notion Database
Create the NotionTable
component to render the fetched data. This component is also going to hold most of the AI works.
Add a file components/notion-table.tsx
with the following code:
// 👇 components/notion-table.tsx
'use client'
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { updateNotionDBRowLink, updateNotionDBRowTitle } from '@/lib/actions'
import { TRowDetails } from '@/types/notion'
import { useCopilotAction, useCopilotReadable } from '@copilotkit/react-core'
import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'
interface NotionTableProps {
initialTableData: TRowDetails[]
}
export const NotionTable = ({ initialTableData }: NotionTableProps) => {
const [tableData, setTableData] = useState<TRowDetails[]>(initialTableData)
return (
<Table className='rounded-sm shadow-sm'>
<TableCaption className='py-4'>Notion Database</TableCaption>
<TableHeader className='bg-zinc-100'>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Link</TableHead>
<TableHead className='text-right'>Due Date</TableHead>
</TableRow>
</TableHeader>
{initialTableData.length === 0 ? (
<p className='text-center text-zinc-500'>No data found.</p>
) : (
<TableBody>
{tableData.map((dbRow, i) => (
<TableRow key={`${dbRow.name}-${dbRow.id}-${i}`}>
<TableCell className='font-medium'>
{dbRow.name ? (
<span>{dbRow.name}</span>
) : (
<span className='text-zinc-500'>Unnamed</span>
)}
</TableCell>
<TableCell>
{dbRow.link ? (
<Link
href={dbRow.link}
aria-label={`Link for ${dbRow.name || 'Unnamed'}`}
target='_blank'
className='underline underline-offset-4'
>
{dbRow.link}
</Link>
) : (
<span className='text-zinc-500'>No Link</span>
)}
</TableCell>
<TableCell className='text-right'>
{dbRow.dueDate.start ? (
<span>{dbRow.dueDate.start}</span>
) : (
<span className='text-zinc-500'>No Due Date</span>
)}
{dbRow.dueDate.end ? ` - ${dbRow.dueDate.end}` : null}
</TableCell>
</TableRow>
))}
</TableBody>
)}
</Table>
)
}
To enable AI to understand and interact with our application, we will leverage a few hooks from CopilotKit.
Specifically, we'll use the useCopilotReadable
and useCopilotAction
hooks to provide AI with both context and the ability to perform actions within our app.
- Provide Read Access with
useCopilotReadable
In the notion-table.tsx
, add the useCopilotReadable
hook to make the AI aware of the application's state:
// ...Rest of the code
useCopilotReadable({
description:
'All the rows in our notion database which holds the information for all the meetings I need to attend.',
value: tableData,
})
// ...Rest of the code
By adding this, CopilotKit gains a real-time understanding of the tableData
state, allowing the AI to answer queries about the Notion database.
- Enable write access with
useCopilotAction
Next, we implement the ability for AI to modify the data. Add the following hooks below the useCopilotReadable
in the same file to allow updates to row details:
// 👇 components/notion-table.tsx
// ...Rest of the code
useCopilotAction({
name: 'updateRowName',
description:
'Update the title of the row (index starts from 0) in the notion database.',
parameters: [
{
name: 'index',
description: 'Index of the row to update.',
required: true,
},
{
name: 'newTitle',
description: 'New title for the row.',
required: true,
},
],
handler: async ({ index, newTitle }) => {
const parsedIndex = parseInt(index, 10)
if (isNaN(parsedIndex)) throw new Error('Invalid index')
const { success } = await updateNotionDBRowTitle({
tableRowId: tableData[parsedIndex].id,
tableRowNewTitle: newTitle,
})
if (!success) return toast.error('Could not update the notion DB')
toast.success('Successfully updated the notion DB')
setTableData(prevData => {
const updatedTableData = [...prevData]
if (parsedIndex >= 0 && parsedIndex < updatedTableData.length) {
updatedTableData[parsedIndex].name = newTitle
}
return updatedTableData
})
},
})
useCopilotAction({
name: 'updateRowLink',
description:
'Update the link of the row (index starts from 0) in the notion database.',
parameters: [
{
name: 'index',
description: 'Index of the row to update.',
required: true,
},
{
name: 'newLink',
description: 'New link to the row.',
required: true,
},
],
handler: async ({ index, newLink }) => {
const parsedIndex = parseInt(index, 10)
if (isNaN(parsedIndex)) throw new Error('Invalid index')
const { success } = await updateNotionDBRowLink({
tableRowId: tableData[parsedIndex].id,
tableRowNewLink: newLink,
})
if (!success) return toast.error('Could not update the notion DB')
toast.success('Successfully updated the notion DB')
setTableData(prevData => {
const updatedTableData = [...prevData]
if (parsedIndex >= 0 && parsedIndex < updatedTableData.length) {
updatedTableData[parsedIndex].link = newLink
}
return updatedTableData
})
},
})
// ...Rest of the code
Each useCopilotAction
hook includes:
-
name
: The action's identifier. -
description
: A clear explanation of the action. -
parameters
: The inputs required for the action. -
handler
: The function that executes the action, modifying the database and updating the UI accordingly.
Implement Database Update Functions
We have not yet written the updateNotionDBRowTitle
and the updateNotionDBRowLink
function. In the lib/actions.ts
file, define the helper functions to interact with the Notion API:
// 👇 lib/actions.ts
'use server'
import {
NOTION_DB_PROPERTY_LINK,
NOTION_DB_PROPERTY_NAME,
} from '@/lib/constants'
// ...Rest of the code
export const updateNotionDBRowTitle = async ({
tableRowId,
tableRowNewTitle,
}: {
tableRowId: string
tableRowNewTitle: string
}): Promise<TResponse> => {
try {
await notion.pages.update({
page_id: tableRowId,
properties: {
[NOTION_DB_PROPERTY_NAME]: {
title: [{ text: { content: tableRowNewTitle } }],
},
},
})
return { success: true, error: null } as TResponse
} catch (error) {
return { success: false, error: error as Error } as TResponse
}
}
export const updateNotionDBRowLink = async ({
tableRowId,
tableRowNewLink,
}: {
tableRowId: string
tableRowNewLink: string
}): Promise<TResponse> => {
try {
await notion.pages.update({
page_id: tableRowId,
properties: {
[NOTION_DB_PROPERTY_LINK]: {
url: tableRowNewLink,
},
},
})
return { success: true, error: null } as TResponse
} catch (error) {
return { success: false, error: error as Error } as TResponse
}
}
Both functions use the Notion API to update specific fields of a row (or "page") in the database, returning success or error responses.
That is all we will be implementing in our application today. You also have the function to delete a row in a database. Feel free to explore the notion API docs and implement it by yourself. 😎
Conclusion ⚡
Wow! Now, we have a fully functioning AI-automated application that can fetch a user's data from their Notion database, answer user queries on the data, and even change the data if needed. 🫨
If you got confused while coding along, the entire documented source code for this article is available here: Github Repo
Star the CopilotKit repository ⭐
Follow CopilotKit for more content like this.
Share your thoughts in the comment section below! 👇
Thank you so much for reading! 🎉 🫡
Top comments (12)
This is an awesome tutorial Arindam!
Thanks for Checking this out Nathan!
Great one. 👏🏻
Thanks for checking out!
Awesomely detailed tutorial! Will try this out for sure!
Great. Let me know what you build with it!
This looks like a worthy project to build.
Thanks!
Glad you liked it.
Can CopilotKit work in Google sheets?
Awesome tutorial I will build something cool on it
Awesome. Let me know how that goes!
Cool!