Fluid Integration Guide
Add AI-generated, schema-driven UIs to any Next.js project in 7 stages. From npm install to a live chatbot that lets users reshape their interface in real time.
How Fluid Works
Fluid operates on a simple principle: your schema declares what's possible, the LLM decides what to show, and the renderer makes it real. No per-user component code, no per-user customization files.
The IR (Intermediate Representation) is the core innovation — sandboxed JSON that can only reference entities and fields you declared. No script injection, no unauthorized API calls, fully Zod-validated.
Fluid is distributed as a set of workspace packages. Add the packages you need:
# Core packages (required) bun add @fluid/core @fluid/engine @fluid/react # Database adapters (recommended for production) bun add @fluid/db # Telemetry & context enrichment (optional) bun add @fluid/telemetry
| Package | Purpose | Required? |
|---|---|---|
@fluid/core | IR types, schema definition, Zod validation | Yes |
@fluid/engine | LLM orchestration, caching, rate limiting | Yes |
@fluid/react | FluidView renderer, FluidChat widget, hooks | Yes |
@fluid/db | Drizzle ORM + Postgres adapters for all stores | Prod |
@fluid/telemetry | Context enricher, refresh policy | No |
Create a .fluid.ts file that declares your data entities, fields, and API endpoints. This is the single source of truth — the LLM can only generate UIs that reference what you declare here.
import { defineSchema } from "@fluid/core"; export const crmSchema = defineSchema({ name: "crm", entities: { Deal: { fields: { name: { type: "string", label: "Deal Name" }, value: { type: "number", label: "Value ($)" }, stage: { type: "enum", values: ["prospect", "qualified", "proposal", "won", "lost"], label: "Stage", }, owner: { type: "string", label: "Owner" }, }, }, Contact: { fields: { name: { type: "string", label: "Name" }, email: { type: "string", label: "Email" }, role: { type: "string", label: "Role" }, }, }, }, endpoints: { deals: { entity: "Deal", fetch: () => fetchDeals() }, contacts: { entity: "Contact", fetch: () => fetchContacts() }, }, });
The schema is your security boundary. The LLM cannot reference any entity, field, or endpoint not declared here. checkIRAgainstSchema() validates this at runtime.
Create a server-only engine singleton. The engine orchestrates LLM calls, caching, profile storage, and rate limiting.
import "server-only"; import { createEngine, createGeminiProvider } from "@fluid/engine"; import { createDbConnection, createPgCacheAdapter, createPgProfileStore, createPgUsageTracker, } from "@fluid/db"; import { createContextEnricher, createRefreshPolicy } from "@fluid/telemetry"; let engine: ReturnType<typeof createEngine> | null = null; export function getEngine() { if (engine) return engine; const db = createDbConnection(process.env.DATABASE_URL!); engine = createEngine({ // Pick your LLM provider provider: createGeminiProvider(process.env.GEMINI_API_KEY!), // Postgres-backed adapters (swap for in-memory in dev) cache: createPgCacheAdapter(db), profileStore: createPgProfileStore(db), usageTracker: createPgUsageTracker(db), // Optional: telemetry enrichment contextEnricher: createContextEnricher(), refreshPolicy: createRefreshPolicy(), }); return engine; }
For local development without a database, omit the Postgres adapters — Fluid falls back to in-memory stores automatically. Just pass provider and you're good.
Environment Variables
# LLM provider (pick one) GEMINI_API_KEY=your-google-ai-studio-key # ANTHROPIC_API_KEY=sk-ant-... # Database (required for prod, optional for dev) DATABASE_URL=postgresql://user:pass@host/db?sslmode=require
Wire up Next.js API routes that your frontend will call. At minimum you need /api/generate and /api/chat.
/api/generate — Intent → IR
import { NextRequest, NextResponse } from "next/server"; import { getEngine } from "@/lib/engine"; import { crmSchema } from "@/schemas/crm.fluid"; export async function POST(req: NextRequest) { const { intent, userId, learn } = await req.json(); const engine = getEngine(); // Rate limit by IP const ip = req.headers.get("x-forwarded-for") ?? "unknown"; const limit = await engine.checkRateLimit(`gen:${ip}`); if (!limit.ok) return NextResponse.json( { error: "Rate limited" }, { status: 429 } ); // Generate or refine (with learning) const result = learn ? await engine.refine({ schema: crmSchema, userId, intent }) : await engine.generate({ schema: crmSchema, intent }); return NextResponse.json(result); }
/api/chat — Conversational IR Patching
import { NextRequest, NextResponse } from "next/server"; import { getEngine } from "@/lib/engine"; import { crmSchema } from "@/schemas/crm.fluid"; export async function POST(req: NextRequest) { const { message, currentIR, userId, schemaName } = await req.json(); if (!currentIR) return NextResponse.json( { reply: "Generate a UI first." }, { status: 400 } ); const engine = getEngine(); const result = await engine.patch({ schema: crmSchema, currentIR, message, userId, }); return NextResponse.json({ reply: result.explanation, canApply: result.applied, newIR: result.ir, newSnapshotId: result.snapshotId, newVersion: result.version, }); }
Use <FluidView> to render any IR. It validates the IR with Zod, checks it against your schema, then recursively renders to React.
import { FluidView } from "@fluid/react"; import { crmSchema } from "@/schemas/crm.fluid"; export default async function Page() { // Fetch your data server-side const data = { deals: await fetchDeals(), contacts: await fetchContacts(), }; // Fetch or load a cached IR const ir = await getDefaultIR(); return ( <FluidView ir={ir} data={data} schema={crmSchema} /> ); }
FluidView runs Zod validation + checkIRAgainstSchema() before rendering. If the IR references an undeclared entity or field, it shows an error instead of crashing.
Drop in <FluidChat> to give users a floating chat widget that can modify the live UI. Users type natural language instructions and the IR updates in real time.
"use client"; import { useState } from "react"; import { FluidView, FluidChat } from "@fluid/react"; import type { FluidIR } from "@fluid/core"; export function Dashboard({ initialIR, data, schema }) { const [ir, setIR] = useState<FluidIR>(initialIR); const [snapshotId, setSnapshotId] = useState<string>(); return ( <> {/* The rendered UI */} <FluidView ir={ir} data={data} schema={schema} /> {/* Chat widget — bottom-right floating bubble */} <FluidChat userId={userId} schemaName="crm" currentSnapshotId={snapshotId} currentIR={ir} // ← sends IR to server for patching onIRChange={(newIR, newSnapshotId, version) => { setIR(newIR); setSnapshotId(newSnapshotId); }} /> </> ); }
The chatbot supports:
- Conversational patching — "Move activities above deals", "Show contacts as cards"
- Version history — Browse and revert to any previous snapshot
- AI suggestions — Proactive improvements based on usage telemetry
Track how users interact with the generated UI and dispatch mutations from action buttons.
import { useFluidTelemetry, useMutations } from "@fluid/react"; // Track clicks, views, scrolls — batched to /api/telemetry useFluidTelemetry({ userId, schemaName: "crm", irId: currentIrId, endpoint: "/api/telemetry", device: window.innerWidth < 768 ? "mobile" : "desktop", }); // Handle mutation actions from generated buttons useMutations({ endpoint: "/api/mutate", onSuccess: (mutation, args) => console.log(`Done: ${mutation}`), onError: (mutation, err) => console.error(`Failed: ${mutation}`), });
Telemetry requires a user profile in fluid_user_profiles. If you're using auto-generated user IDs, ensure your /api/telemetry route auto-creates profiles to prevent FK violations.
API Reference
Engine Methods
| Method | Purpose | State |
|---|---|---|
engine.generate() | Stateless IR generation from intent | No profile read/write |
engine.refine() | Learning generation — reads/writes user profile | Reads + writes ProfileStore |
engine.patch() | Conversational IR modification | Takes currentIR + message |
engine.checkRateLimit() | Sliding-window rate limiting | In-memory or distributed |
engine.checkRefresh() | Should this user's IR be regenerated? | Uses RefreshPolicy |
React Components & Hooks
| Export | Type | Purpose |
|---|---|---|
<FluidView> | Component | Renders IR → React tree with validation |
<FluidChat> | Component | Floating chatbot widget |
useFluidChat() | Hook | Chat state, messages, suggestions, revert |
useFluidTelemetry() | Hook | Batched usage event tracking |
useMutations() | Hook | Action dispatch for generated buttons |
useFluidIR() | Hook | Client-side IR fetching with AbortController |
fetchIR() | Function | Typed fetch wrapper for /api/generate |
LLM Providers
Fluid ships with two providers. Both implement the same LLMProvider interface — swap them without changing any other code.
| Provider | Model | Factory | Env Var |
|---|---|---|---|
| Anthropic | Claude Opus 4.7 | createAnthropicProvider(key) | ANTHROPIC_API_KEY |
| Gemini | Gemini 2.0 Flash | createGeminiProvider(key) | GEMINI_API_KEY |
Adding a Custom Provider
import type { LLMProvider, GenerateOptions, GenerateResult } from "@fluid/engine"; export class MyProvider implements LLMProvider { readonly name = "my-llm"; async generate(opts: GenerateOptions): Promise<GenerateResult> { // Call your LLM, return { text, usage, model, provider } } } // Then pass to engine: createEngine({ provider: new MyProvider() });
Database Setup
@fluid/db uses Drizzle ORM and creates fluid_* namespaced tables that won't collide with your app's tables.
# Push the Drizzle schema to your Postgres
cd packages/fluid-db && npx drizzle-kit push
Tables Created
| Table | Purpose |
|---|---|
fluid_user_profiles | Intent history per user (bounded list) |
fluid_ir_cache | Validated IR cache (keyed by schema + intent hash) |
fluid_usage_events | Click/view/scroll telemetry events |
fluid_snapshots | IR version chain (generate → patch → revert) |
fluid_chat_messages | Chat conversation history |
fluid_suggestions | AI-generated improvement suggestions |
All adapters have in-memory defaults. You don't need a database for local development — profiles and cache will simply reset on server restart.
Built with Fluid · Back to Home