Skip to content
kitn AI/UI

Next.js & TanStack Start

AI/UI elements are custom elements with Shadow DOM, so they hydrate and run in the browser. In Next.js and TanStack Start that makes them client islands: the page renders on the server, and kai-chat comes alive where it mounts.

Register the elements in the browser, then render the tag. The cleanest path is a client component that imports @kitn.ai/ui/elements in an effect, so the import never runs during the server pass.

app/chat-island.tsx
'use client';
import { useEffect, useRef } from 'react';
export default function ChatIsland() {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
import('@kitn.ai/ui/elements').then(() => {
// element is upgraded; set array props in JS, wire kai-submit, stream the reply
if (ref.current) (ref.current as any).messages = [];
});
}, []);
return <kai-chat ref={ref} style={{ display: 'block', height: '600px' }} />;
}

Render the island from a server page — only the island ships JS; the rest stays static:

app/page.tsx
import ChatIsland from './chat-island';
export default function Page() {
return (
<main>
<h1>Support</h1>
<ChatIsland />
</main>
);
}

next/dynamic with ssr: false is the other common gate — but call it from a 'use client' file, not a Server Component, or Next refuses to build.

Keep your model key on the server. The App Router route handler exports an async POST that calls your provider and streams the response back:

app/api/chat/route.ts
export async function POST(req: Request) {
const { messages } = await req.json();
const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({ model: 'gpt-4o-mini', messages, stream: true }),
});
return new Response(upstream.body, {
headers: { 'content-type': 'text/event-stream' },
});
}

The browser reads that OpenAI-format SSE and mutates messages token by token — the Streaming recipe is the exact reader loop, so wire your island’s fetch('/api/chat') to it rather than rewriting it here.

Same idea, different gates. Wrap the chat in ClientOnly from @tanstack/react-router so it mounts only after hydration, and register the elements in an effect:

import { ClientOnly } from '@tanstack/react-router';
import { useEffect, useRef } from 'react';
function Chat() {
const ref = useRef<HTMLElement>(null);
useEffect(() => { import('@kitn.ai/ui/elements'); }, []);
return <kai-chat ref={ref} style={{ display: 'block', height: '600px' }} />;
}
export function ChatRoute() {
return (
<ClientOnly fallback={<div style={{ height: 600 }} />}>
<Chat />
</ClientOnly>
);
}

The fallback reserves the chat’s height, so the layout holds steady through hydration. Your server route is a TanStack Start server route that streams from your provider — the same POST shape as above.

Setting array props through a ref works, but @kitn.ai/ui/react gives you typed props and on* event handlers instead. Events are kai-prefixed custom events with their payload on event.detail:

import { Chat } from '@kitn.ai/ui/react';
<Chat
messages={messages}
onSubmit={(e) => setMessages((m) => [...m, { id: crypto.randomUUID(), role: 'user', content: e.detail.value }])}
style={{ display: 'block', height: '600px' }}
/>;

The adapter still hydrates client-side, so it lives behind the same ssr: false / ClientOnly gate as the raw element.

The same elements run in a desktop shell. An Electron or Tauri webview loads the bundle and the @kitn.ai/ui/elements import upgrades the tags identically — no separate build, no different API.