The chat endpoint accepts a single message + chat ID. History loads server-side—you control storage, condensing, and context limits.
POST /api/chat
{ message: { role: 'user', content: '...' }, id: 'chat-123' }
Two hooks handle persistence:
ai-search:chat:load — Load history before generationai-search:chat:finish — Save messages after generationimport { relations } from 'drizzle-orm'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const chats = sqliteTable('chats', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
title: text('title'),
userId: text('user_id').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
})
export const chatsRelations = relations(chats, ({ many }) => ({
messages: many(messages),
}))
export const messages = sqliteTable('messages', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
role: text('role', { enum: ['user', 'assistant'] }).notNull(),
parts: text('parts', { mode: 'json' }).$type<unknown[]>(),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
})
export const messagesRelations = relations(messages, ({ one }) => ({
chat: one(chats, {
fields: [messages.chatId],
references: [chats.id],
}),
}))
The parts column stores AI SDK's message format as JSON—text, images, tool calls in one field.
pnpm add drizzle-orm better-sqlite3
pnpm add -D drizzle-kit @types/better-sqlite3
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from '../database/schema'
const sqlite = new Database('.data/chat.db')
export const db = drizzle(sqlite, { schema })
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './server/database/schema.ts',
out: './server/database/migrations',
dialect: 'sqlite',
dbCredentials: { url: '.data/chat.db' },
})
pnpm drizzle-kit generate && pnpm drizzle-kit migrate
Load previous messages when a chat ID is provided:
import type { ChatLoadContext, ChatLoadResult } from 'nuxt-ai-search'
import { eq } from 'drizzle-orm'
import { defineNitroPlugin } from 'nitropack/runtime'
import { chats, messages } from '../database/schema'
import { db } from '../utils/db'
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('ai-search:chat:load', async (ctx: ChatLoadContext, result: ChatLoadResult) => {
if (!ctx.chatId) return
const userId = ctx.event.context.userId
if (!userId) return
const chat = await db.query.chats.findFirst({
where: eq(chats.id, ctx.chatId),
with: { messages: { orderBy: (m, { asc }) => [asc(m.createdAt)] } },
})
if (chat && chat.userId !== userId) {
throw createError({ statusCode: 403, message: 'Not your chat' })
}
if (chat?.messages.length) {
result.history = chat.messages.map(m => ({
role: m.role as 'user' | 'assistant',
content: m.parts as any, // parts is JSON array of message parts
}))
}
})
})
Save both user and assistant messages after generation:
import type { ChatFinishContext } from 'nuxt-ai-search'
// Add to same plugin file
nitro.hooks.hook('ai-search:chat:finish', async (ctx: ChatFinishContext) => {
if (!ctx.chatId) return
const userId = ctx.event.context.userId
if (!userId) return
// Create chat if new
const existing = await db.query.chats.findFirst({
where: eq(chats.id, ctx.chatId),
})
if (!existing) {
const firstMsg = ctx.messages.find(m => m.role === 'user')
const title = typeof firstMsg?.content === 'string'
? firstMsg.content.slice(0, 50)
: 'New Chat'
await db.insert(chats).values({
id: ctx.chatId,
userId,
title,
})
}
// Save user message
const userMsg = ctx.messages.at(-2) // second to last
if (userMsg?.role === 'user') {
await db.insert(messages).values({
chatId: ctx.chatId,
role: 'user',
parts: typeof userMsg.content === 'string'
? [{ type: 'text', text: userMsg.content }]
: userMsg.content,
})
}
// Save assistant response
await db.insert(messages).values({
chatId: ctx.chatId,
role: 'assistant',
parts: [{ type: 'text', text: ctx.responseText }],
})
})
Long conversations burn tokens. Condense old messages in the load hook:
nitro.hooks.hook('ai-search:chat:load', async (ctx: ChatLoadContext, result: ChatLoadResult) => {
// ... load messages ...
if (chat?.messages.length > 20) {
// Keep last 10 messages, summarize the rest
const oldMessages = chat.messages.slice(0, -10)
const recentMessages = chat.messages.slice(-10)
const summary = await summarizeMessages(oldMessages, ctx.model)
result.history = [
{ role: 'system', content: `Previous conversation summary: ${summary}` },
...recentMessages.map(m => ({
role: m.role as 'user' | 'assistant',
content: m.parts,
})),
]
}
})
Use AI SDK's useChat with prepareSendMessagesRequest to send only the last message:
<script setup lang="ts">
import { useChat } from '@ai-sdk/vue'
import { DefaultChatTransport } from '@ai-sdk/ui-utils'
const route = useRoute()
const chatId = route.params.id as string
const { messages, input, handleSubmit, isLoading } = useChat({
id: chatId,
transport: new DefaultChatTransport({
api: '/api/chat',
prepareSendMessagesRequest({ messages, id }) {
return {
body: {
message: messages[messages.length - 1],
id,
},
}
},
}),
})
// Load existing messages on mount
const { data: chat } = await useFetch(`/api/chats/${chatId}`)
if (chat.value?.messages) {
messages.value = chat.value.messages.map(m => ({
id: m.id,
role: m.role,
content: typeof m.parts?.[0] === 'object' ? m.parts[0].text : '',
}))
}
</script>
<template>
<div>
<div v-for="m in messages" :key="m.id">
<strong>{{ m.role }}:</strong> {{ m.content }}
</div>
<form @submit="handleSubmit">
<input v-model="input" :disabled="isLoading">
<button type="submit" :disabled="isLoading">
Send
</button>
</form>
</div>
</template>
import { desc, eq } from 'drizzle-orm'
import { chats } from '../database/schema'
import { db } from '../utils/db'
export default defineEventHandler(async (event) => {
const userId = event.context.userId
return db.query.chats.findMany({
where: eq(chats.userId, userId),
orderBy: [desc(chats.createdAt)],
columns: { id: true, title: true, createdAt: true },
})
})
import { eq } from 'drizzle-orm'
import { chats } from '../../database/schema'
import { db } from '../../utils/db'
export default defineEventHandler(async (event) => {
const chatId = event.context.params?.chatId
const userId = event.context.userId
const chat = await db.query.chats.findFirst({
where: eq(chats.id, chatId),
with: { messages: { orderBy: (m, { asc }) => [asc(m.createdAt)] } },
})
if (!chat) throw createError({ statusCode: 404 })
if (chat.userId !== userId) throw createError({ statusCode: 403 })
return chat
})
import { eq } from 'drizzle-orm'
import { chats } from '../../database/schema'
import { db } from '../../utils/db'
export default defineEventHandler(async (event) => {
const chatId = event.context.params?.chatId
const userId = event.context.userId
const chat = await db.query.chats.findFirst({
where: eq(chats.id, chatId),
})
if (!chat) throw createError({ statusCode: 404 })
if (chat.userId !== userId) throw createError({ statusCode: 403 })
await db.delete(chats).where(eq(chats.id, chatId))
return { success: true }
})
No auth? Use a visitor cookie:
import { getCookie, setCookie } from 'h3'
export default defineEventHandler((event) => {
let visitorId = getCookie(event, 'visitor_id')
if (!visitorId) {
visitorId = crypto.randomUUID()
setCookie(event, 'visitor_id', visitorId, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 365,
})
}
event.context.userId = visitorId
})
Anonymous chats pile up. Delete after 30 days:
import { and, lt, like } from 'drizzle-orm'
export default defineTask({
meta: { name: 'chat:cleanup' },
async run() {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
await db.delete(chats).where(
and(
lt(chats.createdAt, thirtyDaysAgo),
like(chats.userId, '%-%-%-%-%'),
),
)
},
})