AI SDK (with Next.js)
AI SDK streams model responses to the browser, and a Next.js route handler gives you a place to persist each exchange with Prisma ORM, so a conversation survives page reloads.
In this guide, you scaffold a Next.js app with Prisma ORM, define a contract for chat sessions and messages, save every completed exchange from the AI SDK route handler, and load a session back into the UI when the page opens.
- Node.js 24 or later
- An OpenAI API key
- A PostgreSQL connection string, or nothing at all:
npx create-db@latestcan create a Prisma Postgres database for you
To delegate this guide to your coding agent, copy the prompt below and hand it over:
Build a Next.js chat app with AI SDK that stores chat sessions and messages with Prisma ORM, following https://www.prisma.io/docs/guides/integrations/ai-sdk.md.
1. Scaffold: `npx create-prisma@latest create ai-sdk-prisma --template next --provider postgres --yes`. Then run `npx prisma@latest init` in `ai-sdk-prisma` so the Prisma agent skills are installed and stay current, and use them. Get a database connection string: use the one I give you, or create a Prisma Postgres database with `npx create-db@latest` and show me the claim URL it prints. Export it as `DATABASE_URL` in the shell; the Prisma CLI reads the environment variable, not `.env`.
2. Replace `src/prisma/contract.prisma` with the `Session` and `Message` models from the guide (a `MessageRole` enum, `parts` stored as `Json`, `position` for ordering, `onDelete: Cascade` on the relation). Delete the starter files the template generated for its own schema: `src/prisma/users.ts`, `src/prisma/seed.ts`, and the `migrations/` directory. Run `npm run contract:emit` and then `npm run db:init` with `DATABASE_URL` exported.
3. Install `ai`, `@ai-sdk/react`, and `@ai-sdk/openai`. Put `OPENAI_API_KEY` and `DATABASE_URL` in `.env`.
4. Create `src/prisma/chat.ts` with `saveChat(id, messages)` (upsert the session, then upsert each message by id inside `db.transaction`) and `loadChat(id)` (messages for a session ordered by `position`), `src/app/api/chat/route.ts` (streamText with `openai("gpt-5.1")`, `toUIMessageStream` with `originalMessages`, `generateMessageId`, and an `onEnd` that calls `saveChat`), `src/app/api/messages/route.ts` (GET, `?chat=<id>`), a server `src/app/page.tsx` that redirects to `/?chat=<generateId()>` when the id is missing, and a client `src/app/chat.tsx` built on `useChat({ id })` that fetches `/api/messages` on mount.
5. Run `npx tsc --noEmit` and `npm run build`. Start `npm run dev` in the background, open http://localhost:3000, send a message, reload the page, and confirm the conversation is still there. Stop the dev server when done.The next template generates a Next.js app with Prisma ORM already wired in: the contract, the client in src/prisma/db.ts, and package scripts for the database steps. That replaces the create-next-app, prisma init, driver adapter, and prisma generate steps of the Prisma ORM 7 guide.
bunx create-prisma@latest create ai-sdk-prisma --template next --provider postgres --no-deploy
cd ai-sdk-prismapnpm dlx create-prisma@latest create ai-sdk-prisma --template next --provider postgres --no-deploy
cd ai-sdk-prismayarn dlx create-prisma@latest create ai-sdk-prisma --template next --provider postgres --no-deploy
cd ai-sdk-prismanpx create-prisma@latest create ai-sdk-prisma --template next --provider postgres --no-deploy
cd ai-sdk-prismaAnswer the prompts for contract authoring style (this guide uses PSL), package manager, and agent skills. The scaffold installs dependencies and emits the contract your queries are type-checked against.
Next, set the database connection for the CLI steps. Use your own PostgreSQL connection string, or create a Prisma Postgres database with npx create-db@latest; it prints a connection string and a claim URL you can open to keep the database. Export the variable in the shell you work in; the Prisma CLI reads the environment variable, not .env:
export DATABASE_URL="<your connection string>"Answer the prompts for contract authoring style (this guide uses PSL), package manager, and agent skills. The scaffold installs dependencies and emits the contract your queries are type-checked against.
Next, set the database connection for the CLI steps. Use your own PostgreSQL connection string, or create a Prisma Postgres database with npx create-db@latest; it prints a connection string and a claim URL you can open to keep the database. Export the variable in the shell you work in; the Prisma CLI reads the environment variable, not .env:
export DATABASE_URL="<your connection string>"The template ships a User and Post starter schema. Replace the whole of src/prisma/contract.prisma with the chat models:
// use prisma-8
enum MessageRole {
@@type("pg/text@1")
user = "user"
assistant = "assistant"
}
model Session {
id String @id
createdAt TimestamptzString @default(now())
updatedAt temporal.updatedAtString()
messages Message[]
}
model Message {
id String @id
role MessageRole
parts Json
position Int
createdAt TimestamptzString @default(now())
sessionId String
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
}A few things to note:
Session.idandMessage.idhave no default. AI SDK generates a chat id on the client and stable message ids on the server, and you store those.MessageRoleis a Prisma ORM enum: it is stored astextand enforced with aCHECKconstraint. The values match AI SDK'srolestrings, so you can savemessage.roleas is.partsisJson. AI SDK messages carry an array of parts (text, reasoning, tool calls), and storing it verbatim means the UI can render a saved message exactly like a live one.positionrecords where a message sits in the conversation, soloadChatcan return messages in order.
The template also generated a seed helper and an initial migration for the starter schema. Remove them so nothing references models that no longer exist:
rm src/prisma/users.ts src/prisma/seed.ts
rm -r migrationsEmit the contract and initialize the database:
bun run contract:emit
bun run db:initpnpm run contract:emit
pnpm run db:inityarn contract:emit
yarn db:initnpm run contract:emit
npm run db:init"summary": "Applied 4 operation(s) across 1 space(s), database signed"contract:emit regenerates src/prisma/contract.json and src/prisma/contract.d.ts, which is where your query types come from. db:init creates the session and message tables, the index on sessionId, and the cascading foreign key, then signs the database. There is no separate generate step and no client to instantiate: src/prisma/db.ts already exports db, and its types follow the contract you just emitted.
If db:init stops with Connection terminated unexpectedly, a database you just created is still starting; wait a few seconds and run it again.
"summary": "Applied 4 operation(s) across 1 space(s), database signed"contract:emit regenerates src/prisma/contract.json and src/prisma/contract.d.ts, which is where your query types come from. db:init creates the session and message tables, the index on sessionId, and the cascading foreign key, then signs the database. There is no separate generate step and no client to instantiate: src/prisma/db.ts already exports db, and its types follow the contract you just emitted.
If db:init stops with Connection terminated unexpectedly, a database you just created is still starting; wait a few seconds and run it again.
bun add ai @ai-sdk/react @ai-sdk/openaipnpm add ai @ai-sdk/react @ai-sdk/openaiyarn add ai @ai-sdk/react @ai-sdk/openainpm install ai @ai-sdk/react @ai-sdk/openaiAdd your OpenAI API key to .env. Put DATABASE_URL there too: next dev and next build load .env, so the app finds both values without the shell export. The Prisma CLI does not read .env, which is why you still export DATABASE_URL for db:init and the other prisma commands.
DATABASE_URL="<your connection string>"
OPENAI_API_KEY="<your OpenAI API key>"Add your OpenAI API key to .env. Put DATABASE_URL there too: next dev and next build load .env, so the app finds both values without the shell export. The Prisma CLI does not read .env, which is why you still export DATABASE_URL for db:init and the other prisma commands.
DATABASE_URL="<your connection string>"
OPENAI_API_KEY="<your OpenAI API key>"Create src/prisma/chat.ts with two functions: saveChat persists a session and its messages, and loadChat reads them back in order.
import type { JsonValue } from "@prisma/orm-postgres/target/codec-types";
import type { UIMessage } from "ai";
import { db } from "./db.ts";
export async function saveChat(id: string, messages: UIMessage[]) {
await db.transaction(async (tx) => {
await tx.orm.public.Session.upsert({
create: { id },
update: {},
conflictOn: { id },
});
for (const [position, message] of messages.entries()) {
if (message.role !== "user" && message.role !== "assistant") continue;
await tx.orm.public.Message.upsert({
create: {
id: message.id,
sessionId: id,
role: message.role,
parts: message.parts as JsonValue,
position,
},
update: { parts: message.parts as JsonValue, position },
conflictOn: { id: message.id },
});
}
});
}
export async function loadChat(id: string): Promise<UIMessage[]> {
const rows = await db.orm.public.Message.select("id", "role", "parts")
.where({ sessionId: id })
.orderBy((m) => m.position.asc())
.all();
return rows.map((row) => ({
id: row.id,
role: row.role,
parts: row.parts as UIMessage["parts"],
}));
}How it works:
- Model access is namespace-qualified on PostgreSQL:
db.orm.public.Session, notdb.session.db.transactionhands you atxwith the sameormsurface, and everything inside it commits together or not at all. upserttakescreate,update, andconflictOn. AI SDK sends the whole conversation with every request, so upserting each message by its id keeps saves idempotent: messages that already exist are left alone, and the new user and assistant messages are inserted.message.partsis typed by AI SDK as an array of part objects. Theas JsonValuecast tells Prisma ORM to store it in theJsoncolumn; the reverse cast inloadChathands it back to AI SDK asUIMessage["parts"]..select("id", "role", "parts")narrows the returned row type to the fields the UI needs, and.orderBy((m) => m.position.asc())restores conversation order.
The route handler streams the model response to the browser and, once the stream ends, saves the full conversation:
mkdir -p src/app/api/chatimport { openai } from "@ai-sdk/openai";
import {
convertToModelMessages,
createIdGenerator,
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
type UIMessage,
} from "ai";
import { saveChat } from "../../../prisma/chat";
export const maxDuration = 300;
export async function POST(req: Request) {
const { messages, id }: { messages: UIMessage[]; id: string } = await req.json();
const result = streamText({
model: openai("gpt-5.1"),
messages: await convertToModelMessages(messages),
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({
stream: result.stream,
originalMessages: messages,
generateMessageId: createIdGenerator({ prefix: "msg", size: 16 }),
onEnd: async ({ messages }) => {
await saveChat(id, messages);
},
}),
});
}This handler:
- Reads the conversation and the chat
idthatuseChatsends in the request body. - Converts the UI messages to model messages and streams the response.
- Passes
originalMessagessoonEndreceives the whole conversation, including the new assistant message, and saves it withsaveChat.
generateMessageId gives the assistant message a stable id on the server before it is stored. Without it the id is empty, and every assistant message in a session would collide on the primary key.
The UI calls this route on load to restore a session:
mkdir -p src/app/api/messagesimport { NextResponse } from "next/server";
import { loadChat } from "../../../prisma/chat";
export async function GET(req: Request) {
const id = new URL(req.url).searchParams.get("chat");
if (!id) {
return NextResponse.json({ error: "Missing chat id" }, { status: 400 });
}
const messages = await loadChat(id);
return NextResponse.json({ messages });
}The page is a server component. It reads the chat id from the URL, creates one when it is missing, and renders the chat component. Replace src/app/page.tsx:
import { generateId } from "ai";
import { redirect } from "next/navigation";
import Chat from "./chat";
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ chat?: string }>;
}) {
const { chat } = await searchParams;
if (!chat) redirect(`/?chat=${generateId()}`);
return <Chat id={chat} />;
}Keeping the id in the URL is what makes a session survive a reload: the same URL loads the same session.
The chat component is a client component. useChat({ id }) sends that id with every request, and the useEffect loads any saved messages into the hook when the component mounts. Create src/app/chat.tsx:
"use client";
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "ai";
import { useEffect, useState } from "react";
export default function Chat({ id }: { id: string }) {
const [input, setInput] = useState("");
const [loading, setLoading] = useState(true);
const { messages, sendMessage, setMessages } = useChat({ id });
useEffect(() => {
fetch(`/api/messages?chat=${id}`)
.then((res) => res.json())
.then((data: { messages: UIMessage[] }) => {
if (data.messages.length > 0) setMessages(data.messages);
setLoading(false);
})
.catch(() => setLoading(false));
}, [id, setMessages]);
if (loading) return <p className="empty">Loading...</p>;
return (
<main className="shell chat">
{messages.map((message) => (
<div key={message.id} className={`bubble ${message.role}`}>
<p className="eyebrow">{message.role === "user" ? "You" : "AI"}</p>
{message.parts.map((part, i) =>
part.type === "text" ? <div key={`${message.id}-${i}`}>{part.text}</div> : null,
)}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage({ text: input });
setInput("");
}}
>
<input
className="composer"
value={input}
placeholder="Say something..."
onChange={(e) => setInput(e.currentTarget.value)}
/>
</form>
</main>
);
}The template does not include Tailwind, so add a few rules to the end of src/app/globals.css for the bubbles and the input:
.chat {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-bottom: 6rem;
}
.bubble {
max-width: 80%;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
white-space: pre-wrap;
background: #f0f0f0;
align-self: flex-start;
}
.bubble.user {
background: #111;
color: #fff;
align-self: flex-end;
}
.bubble .eyebrow {
color: inherit;
opacity: 0.6;
}
.composer {
position: fixed;
bottom: 2rem;
left: 50%;
width: min(40rem, calc(100% - 3rem));
transform: translateX(-50%);
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 0.5rem;
font: inherit;
}Check the types first, then start the dev server:
bunx tsc --noEmit
bun run devpnpm tsc --noEmit
pnpm run devyarn tsc --noEmit
yarn devnpx tsc --noEmit
npm run dev▲ Next.js 16.1.6 (Turbopack)
- Local: http://localhost:3000
- Environments: .env
✓ Ready in 5.5sOpen http://localhost:3000. The page redirects to /?chat=<id>, and the first request compiles the app, so it takes a few seconds. Send a message, wait for the reply, then reload the page: both messages come back from the database.
You can also read a session directly from the API. Replace the id with the one in your address bar. The session below was captured with AI SDK's mock language model instead of a live OpenAI call, which is why the assistant text reads the way it does; the persistence path is the same:
curl "http://localhost:3000/api/messages?chat=<id>"{"messages":[{"id":"user-1789055570091","role":"user","parts":[{"type":"text","text":"Hello from the mock test"}]},{"id":"msg-abc123def456","role":"assistant","parts":[{"type":"step-start"},{"type":"text","text":"Hi! I am a mock model.","state":"done"}]}]}A session that has no messages yet returns {"messages":[]}, and a request without ?chat= returns a 400.
Finally, make sure the production build passes:
▲ Next.js 16.1.6 (Turbopack)
- Local: http://localhost:3000
- Environments: .env
✓ Ready in 5.5sOpen http://localhost. The page redirects to /?chat=<id>, and the first request compiles the app, so it takes a few seconds. Send a message, wait for the reply, then reload the page: both messages come back from the database.
You can also read a session directly from the API. Replace the id with the one in your address bar. The session below was captured with AI SDK's mock language model instead of a live OpenAI call, which is why the assistant text reads the way it does; the persistence path is the same:
curl "http://localhost:3000/api/messages?chat=<id>"{"messages":[{"id":"user-1789055570091","role":"user","parts":[{"type":"text","text":"Hello from the mock test"}]},{"id":"msg-abc123def456","role":"assistant","parts":[{"type":"step-start"},{"type":"text","text":"Hi! I am a mock model.","state":"done"}]}]}A session that has no messages yet returns {"messages":[]}, and a request without ?chat= returns a 400.
Finally, make sure the production build passes:
bun run buildpnpm run buildyarn buildnpm run build✓ Compiled successfully in 30.9s
Route (app)
┌ ƒ /
├ ○ /_not-found
├ ƒ /api/chat
└ ƒ /api/messages✓ Compiled successfully in 30.9s
Route (app)
┌ ƒ /
├ ○ /_not-found
├ ƒ /api/chat
└ ƒ /api/messagessrc/prisma/contract.prisma: your schema. Edit it, then runnpm run contract:emitandnpm run db:update.src/prisma/db.ts: the Prisma ORM client the template generated. It is a module-level singleton, so its connection pool is shared across requests.src/prisma/chat.ts: the two functions that touch the database.src/app/api/chat/route.tsandsrc/app/api/messages/route.ts: the write path and the read path.
db:initand the otherprismascripts readDATABASE_URLfrom the environment, while Next.js reads.env. If a Prisma command reports a missing connection string, export the variable in that shell.- If you keep the template's
migrations/directory and later runnpm run migration:plan, the plan starts from the starterUserandPostschema instead of from an empty database. Delete the directory before your first emit, as in step 2, and runnpx prisma migration plan --name initwhen you want a checked-in migration for your own contract. partsneeds theas JsonValuecast on the way in. AI SDK types the parts array with interfaces, and Prisma ORM'sJsoninput type wants plain JSON values; the cast is safe because the parts are serializable objects.
Run npx prisma@latest init once to install the Prisma ORM skills for your coding agent and keep them matching your installed packages. Prompts that map to this guide:
- "Using the prisma-8 skill, add a
titlefield toSessionand aPATCH /api/sessions/:idroute that updates it." - "Add a
GET /api/sessionsroute that lists sessions with their message count, usingincludewith acount()reducer." - "Add a
DELETE /api/sessions/:idroute and confirm the cascade removes the session's messages."
- Deploy the app to Prisma Compute: the template already declares it in
module.tsandservice.ts. - Learn the fundamentals: filtering, sorting, pagination, and writes.
- Read the Prisma ORM overview for the concepts behind contracts and typed queries.
- AI SDK documentation on message persistence, including sending only the last message and handling client disconnects.