n4nAI

Add persistent chat history to a Next.js AI SDK chatbot

Learn how to add persistent chat history to a Next.js AI SDK chatbot using Drizzle and Postgres, with step-by-step code and verification tips.

n4n Team4 min read833 words

Audio narration

Coming soon — every post will get a voice note here.

Building a next.js ai sdk chatbot persistent chat history requirement is common once you move beyond demos: users expect their conversations to survive refreshes, reconnects, and device switches. This guide implements durable storage with Drizzle ORM and Postgres, then wires it into the Vercel AI SDK’s streaming chat flow so messages are loaded on mount and saved on completion.

Prerequisites

You need a Next.js 14+ App Router project with the following installed:

npm install ai @ai-sdk/openai drizzle-orm postgres-js
npm install -D drizzle-kit

A running Postgres instance with a connection string in DATABASE_URL. The examples use Drizzle’s postgres-js driver. No prior chat code is required, but if you already have a useChat prototype, you can adapt the steps directly.

Step 1: Define the database schema

Postgres is the right default for chat logs: transactional writes, easy JSON storage, and cheap indexed reads. Use Drizzle to keep migrations typed and reviewable. The next.js ai sdk chatbot persistent chat history schema should separate sessions from messages so you can list conversations without loading every turn.

Create a chats table for sessions and a messages table for individual turns. Store the full message object as JSONB to avoid column churn when the SDK changes its message shape.

import { pgTable, uuid, text, timestamp, jsonb } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';

export const chats = pgTable('chats', {
  id: uuid('id').defaultRandom().primaryKey(),
  userId: text('user_id').notNull(),
  title: text('title').default('New chat'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const messages = pgTable('messages', {
  id: uuid('id').defaultRandom().primaryKey(),
  chatId: uuid('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
  role: text('role').notNull(),
  content: jsonb('content').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

Run drizzle-kit generate and drizzle-kit migrate to apply. The content column holds the exact payload the AI SDK sends ({ type: 'text', text: '...' } arrays in v4, or strings in older versions). Add an index on chat_id if you expect long conversations:

// in messages table definition
index('chat_id_idx').on(messages.chatId)

Step 2: Configure the model provider

The Vercel AI SDK decouples your code from a specific vendor. Point it at any OpenAI-compatible endpoint. If you route through n4n.ai, set baseURL to its OpenAI-compatible endpoint to get automatic fallback across 240+ models when a provider is rate-limited or degraded, while keeping the same chat.completions shape.

import { createOpenAI } from '@ai-sdk/openai';

const openai = createOpenAI({
  baseURL: process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1',
  apiKey: process.env.LLM_API_KEY,
});

export const model = openai('gpt-4o-mini');

Keep keys in environment variables. Never expose them to the client. The model export is imported by both the route handler and any server-side batch jobs.

Step 3: Build the chat API route with load and save

App Router route handlers are server-only. Load existing messages by chatId, append the incoming message, stream the response, then persist the assistant reply after the stream closes. This pattern keeps the next.js ai sdk chatbot persistent chat history writes explicit rather than relying on client-side side effects.

import { streamText } from 'ai';
import { db } from '@/lib/db';
import { messages as messagesTable, chats } from '@/lib/schema';
import { eq } from 'drizzle-orm';
import { model } from '@/lib/model';

export async function POST(req: Request) {
  const { chatId, userId, messages } = await req.json();

  const existing = await db.select().from(chats).where(eq(chats.id, chatId));
  if (existing.length === 0) {
    await db.insert(chats).values({ id: chatId, userId });
  }

  const lastUserMsg = messages[messages.length - 1];
  await db.insert(messagesTable).values({
    chatId,
    role: lastUserMsg.role,
    content: lastUserMsg.content,
  });

  const history = await db.select().from(messagesTable).where(eq(messagesTable.chatId, chatId));
  const converted = history.map((m) => ({ role: m.role, content: m.content }));

  const result = streamText({
    model,
    messages: converted,
  });

  result.text.then(async (text) => {
    await db.insert(messagesTable).values({
      chatId,
      role: 'assistant',
      content: [{ type: 'text', text }],
    });
  });

  return result.toDataStreamResponse();
}

This is intentionally minimal. In production, wrap writes in a transaction and add error handling. The key point: the SDK streams to the client while you asynchronously write the final text to Postgres. If you need the exact model used (e.g., after fallback), capture result.providerMetadata and store it alongside the message.

Step 4: Create the client chat component

Use useChat from the AI SDK React bindings. Pass initialMessages fetched from the server to hydrate history. Generate a chatId on the client and send it with each request via body.

'use client';
import { useChat } from 'ai/react';
import { useEffect, useState } from 'react';

export function Chat({ chatId, initialMessages }: { chatId: string; initialMessages: any[] }) {
  const { messages, input, handleInputChange, handleSubmit } = useChat({
    initialMessages,
    body: { chatId, userId: 'demo-user' },
  });

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role}</strong>: {typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}
        </div>
      ))}
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

For the server page, load history before render:

import { db } from '@/lib/db';
import { messages as messagesTable } from '@/lib/schema';
import { eq } from 'drizzle-orm';
import { Chat } from './chat';

export default async function Page({ params }: { params: { chatId: string } }) {
  const rows = await db.select().from(messagesTable).where(eq(messagesTable.chatId, params.chatId));
  const initialMessages = rows.map((r) => ({
    id: r.id,
    role: r.role,
    content: typeof r.content === 'string' ? r.content : JSON.stringify(r.content),
  }));

  return <Chat chatId={params.chatId} initialMessages={initialMessages} />;
}

The AI SDK expects content as a string in the client Message type for text chat. Convert JSONB back to a string if you stored structured content. The next.js ai sdk chatbot persistent chat history now renders instantly from the database instead of empty state.

Step 5: Manage chat creation and user identity

For a real app, derive userId from an auth session, not a hard-coded string. Create the chatId client-side with crypto.randomUUID() when starting a new chat, then route to /chat/[chatId]. The API step above already creates the chat row if missing.

Add a sidebar that lists chats for the user:

const userChats = await db.select().from(chats).where(eq(chats.userId, session.user.id));

Keep this query indexed on user_id for speed. If you want titles, update the chats row with the first user message text after the first turn.

Step 6: Verify the persistence works

Start the dev server and open a new chat. Send a message, wait for the reply, then refresh the page. The messages should reappear immediately because the server component queries Postgres on every load.

To confirm the database write independently, run a psql query:

psql $DATABASE_URL -c "SELECT count(*) FROM messages WHERE chat_id = '<chatId>';"

You should see at least two rows (user + assistant). If you kill the tab mid-stream, the assistant row may be missing—that is expected with the minimal save-on-complete approach. For resilience, buffer stream chunks and write once on onFinish instead of result.text.then.

Check the UI behavior: open the chat in a second browser, navigate to the same chatId, and confirm both see identical history. That proves the next.js ai sdk chatbot persistent chat history is not tied to a single client.

Step 7: Test the full loop with curl

Skip the UI for a quick sanity check. Send a POST to the route and inspect the stream:

curl -X POST http://localhost:3000/api/chat \
  -H 'content-type: application/json' \
  -d '{"chatId":"test-123","userId":"demo","messages":[{"role":"user","content":"Hello"}]}'

Then query the DB again. If rows exist, the pipeline is functioning. This curl test also validates that your provider credentials and base URL are correct before you debug React state.

Production hardening notes

  • Serialize writes per chatId to avoid duplicate assistant rows from double-submits.
  • Store the provider’s cache-control hints if you forward them; they help with cost accounting.
  • Use a connection pooler (e.g., PgBouncer) so high chat volume doesn’t exhaust Postgres connections.

The implementation above is production-adjacent. Swap the hard-coded user for your auth, add transactions, and you have a durable chat backend with no black boxes—just explicit writes around the SDK’s streaming primitive.

Tagsnext-jsvercel-ai-sdkchat-historypersistence

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All building chatbots with vercel ai sdk & next.js posts →