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.

v1.2.0 Next.js 16+ TypeScript ~15 min setup

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.

.fluid.ts Schema
Engine LLM → IR
IR JSON tree
FluidView React SSR
User Personalized UI

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.

01 Install Packages

Fluid is distributed as a set of workspace packages. Add the packages you need:

$ terminal bash
# 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
PackagePurposeRequired?
@fluid/coreIR types, schema definition, Zod validationYes
@fluid/engineLLM orchestration, caching, rate limitingYes
@fluid/reactFluidView renderer, FluidChat widget, hooksYes
@fluid/dbDrizzle ORM + Postgres adapters for all storesProd
@fluid/telemetryContext enricher, refresh policyNo
02 Define Your Schema

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.

src/schemas/crm.fluid.ts TypeScript
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() },
  },
});
ℹ Info

The schema is your security boundary. The LLM cannot reference any entity, field, or endpoint not declared here. checkIRAgainstSchema() validates this at runtime.

03 Configure the Engine

Create a server-only engine singleton. The engine orchestrates LLM calls, caching, profile storage, and rate limiting.

src/lib/engine.ts TypeScript
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;
}
💡 Tip

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

.env.local env
# 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
04 Create API Routes

Wire up Next.js API routes that your frontend will call. At minimum you need /api/generate and /api/chat.

/api/generate — Intent → IR

src/app/api/generate/route.ts TypeScript
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

src/app/api/chat/route.ts TypeScript
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,
  });
}
05 Render the Generated UI

Use <FluidView> to render any IR. It validates the IR with Zod, checks it against your schema, then recursively renders to React.

src/app/page.tsx TypeScript
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}
    />
  );
}
ℹ Validation

FluidView runs Zod validation + checkIRAgainstSchema() before rendering. If the IR references an undeclared entity or field, it shows an error instead of crashing.

06 Add the Chatbot

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.

src/app/Dashboard.tsx TypeScript
"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:

07 Telemetry & Hooks

Track how users interact with the generated UI and dispatch mutations from action buttons.

Telemetry + Mutations TypeScript
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}`),
});
⚠ Important

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

MethodPurposeState
engine.generate()Stateless IR generation from intentNo profile read/write
engine.refine()Learning generation — reads/writes user profileReads + writes ProfileStore
engine.patch()Conversational IR modificationTakes currentIR + message
engine.checkRateLimit()Sliding-window rate limitingIn-memory or distributed
engine.checkRefresh()Should this user's IR be regenerated?Uses RefreshPolicy

React Components & Hooks

ExportTypePurpose
<FluidView>ComponentRenders IR → React tree with validation
<FluidChat>ComponentFloating chatbot widget
useFluidChat()HookChat state, messages, suggestions, revert
useFluidTelemetry()HookBatched usage event tracking
useMutations()HookAction dispatch for generated buttons
useFluidIR()HookClient-side IR fetching with AbortController
fetchIR()FunctionTyped 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.

ProviderModelFactoryEnv Var
AnthropicClaude Opus 4.7createAnthropicProvider(key)ANTHROPIC_API_KEY
GeminiGemini 2.0 FlashcreateGeminiProvider(key)GEMINI_API_KEY

Adding a Custom Provider

my-provider.ts TypeScript
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.

$ terminal bash
# Push the Drizzle schema to your Postgres
cd packages/fluid-db && npx drizzle-kit push

Tables Created

TablePurpose
fluid_user_profilesIntent history per user (bounded list)
fluid_ir_cacheValidated IR cache (keyed by schema + intent hash)
fluid_usage_eventsClick/view/scroll telemetry events
fluid_snapshotsIR version chain (generate → patch → revert)
fluid_chat_messagesChat conversation history
fluid_suggestionsAI-generated improvement suggestions
💡 No DB for dev

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