Sponsored Content

AI SDK Integration

Use json-render with the Vercel AI SDK for seamless streaming. json-render supports two modes: Standalone (standalone UI) and Inline (UI embedded in conversation). See Generation Modes for a detailed comparison.

Installation

npm install ai @ai-sdk/react

Standalone Mode

In standalone mode, the AI outputs only JSONL patches. The entire response is a UI spec with no prose. This is the default mode and is ideal for playgrounds, builders, and dashboard generators.

API Route

// app/api/generate/route.ts
import { streamText } from "ai";
import { catalog } from "@/lib/catalog";

export async function POST(req: Request) {
  const { prompt, currentTree } = await req.json();

  const systemPrompt = catalog.prompt();

  // Optionally include current UI state for context
  const contextPrompt = currentTree
    ? `\n\nCurrent UI state:\n${JSON.stringify(currentTree, null, 2)}`
    : "";

  const result = streamText({
    model: yourModel,
    system: systemPrompt + contextPrompt,
    prompt,
  });

  return result.toTextStreamResponse();
}

Client

Use useUIStream on the client to compile the JSONL stream into a spec:

"use client";

import { useUIStream, Renderer } from "@json-render/react";

function GenerativeUI() {
  const { spec, isStreaming, error, send } = useUIStream({
    api: "/api/generate",
  });

  return (
    <div>
      <button
        onClick={() => send("Create a dashboard with metrics")}
        disabled={isStreaming}
      >
        {isStreaming ? "Generating..." : "Generate"}
      </button>

      {error && <p className="text-red-500">{error.message}</p>}

      <Renderer spec={spec} registry={registry} loading={isStreaming} />
    </div>
  );
}

Inline Mode

In inline mode, the AI responds conversationally and includes JSONL patches inline. Text-only replies are allowed when no UI is needed. This is ideal for chatbots, copilots, and educational assistants.

API Route

Use pipeJsonRender to separate text from JSONL patches in the stream. Patches are emitted as data parts that the client can pick up.

// app/api/chat/route.ts
import { streamText } from "ai";
import { pipeJsonRender } from "@json-render/core";
import {
  createUIMessageStream,
  createUIMessageStreamResponse,
} from "ai";
import { catalog } from "@/lib/catalog";

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

  const result = streamText({
    model: yourModel,
    system: catalog.prompt({ mode: "inline" }),
    messages,
  });

  const stream = createUIMessageStream({
    execute: async ({ writer }) => {
      writer.merge(pipeJsonRender(result.toUIMessageStream()));
    },
  });

  return createUIMessageStreamResponse({ stream });
}

Client

Use useChat from the AI SDK and useJsonRenderMessage from json-render to extract the spec from each message:

"use client";

import { useChat } from "@ai-sdk/react";
import { useJsonRenderMessage, Renderer } from "@json-render/react";

function Chat() {
  const { messages, input, handleInputChange, handleSubmit } = useChat({
    api: "/api/chat",
  });

  return (
    <div>
      <div>
        {messages.map((msg) => (
          <ChatMessage key={msg.id} message={msg} />
        ))}
      </div>

      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Ask something..."
        />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

function ChatMessage({ message }: { message: { parts: Array<{ type: string; text?: string; data?: unknown }> } }) {
  const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);

  return (
    <div>
      {text && <p>{text}</p>}
      {hasSpec && spec && (
        <Renderer spec={spec} registry={registry} />
      )}
    </div>
  );
}

Preserving part order

The example above is a message-level layout: text joins all text parts, and spec combines all spec data parts into one cumulative spec. It renders the joined text followed by the UI, not the original part order.

For mixed inline layouts, iterate message.parts instead. Render the latest merged spec once at the first SPEC_DATA_PART_TYPE position and skip subsequent spec parts. Individual spec parts may be patches, not standalone UI blocks. Later patches update that same UI in place, even when text or tools occur between them; this does not create a separate UI snapshot at each patch position.

import { Fragment, type ReactNode } from "react";
import type { UIMessage } from "ai";
import { SPEC_DATA_PART_TYPE } from "@json-render/core";
import { useJsonRenderMessage, Renderer } from "@json-render/react";

function OrderedChatMessage({
  message,
  renderPart,
}: {
  message: UIMessage;
  renderPart: (part: UIMessage["parts"][number]) => ReactNode;
}) {
  const { spec, hasSpec } = useJsonRenderMessage(message.parts);
  const firstSpecIndex = message.parts.findIndex(
    (part) => part.type === SPEC_DATA_PART_TYPE,
  );

  return (
    <div>
      {message.parts.map((part, index) => {
        if (part.type === SPEC_DATA_PART_TYPE) {
          if (index !== firstSpecIndex || !hasSpec || !spec) return null;
          return <Renderer key={index} spec={spec} registry={registry} />;
        }
        if (part.type === "text") {
          return <p key={index}>{part.text}</p>;
        }
        return <Fragment key={index}>{renderPart(part)}</Fragment>;
      })}
    </div>
  );
}

Here, registry is your component registry and renderPart is your application's renderer for tool, reasoning, source, and other parts (return null for parts you do not display). For text → tool → text → spec patch → text → spec patch, the layout is text → tool → text → merged UI → text. Do not also render the hook's aggregated text, or the text will appear twice.

During streaming, hasSpec can be false while the initial patches have not added any elements yet. The UI appears at the first spec-part position once hasSpec is true; text-only messages render without a UI block. See the chat example's ordered segments for a complete implementation that also groups adjacent text and tool parts.

Prompt Engineering

The catalog.prompt() method creates an optimized system prompt that:

  • Lists all available components and their props
  • Describes available actions
  • Specifies the expected output format (JSONL-only or text + JSONL depending on mode)
  • Includes examples for better generation

Custom Rules

Pass custom rules to tailor AI behavior:

const systemPrompt = catalog.prompt({
  customRules: [
    "Always use Card components for grouping related content",
    "Prefer horizontal layouts (Row) for metrics",
    "Use consistent spacing with padding=\"md\"",
  ],
});

Inline Mode Prompt

const inlinePrompt = catalog.prompt({ mode: "inline" });

In inline mode, the prompt instructs the AI to respond conversationally first, then include JSONL patches on their own lines when UI is needed. Text-only replies are allowed.

Which Mode?

StandaloneInline
OutputJSONL onlyText + JSONL
Text-only repliesNoYes
System promptcatalog.prompt()catalog.prompt({ mode: "inline" })
Stream utilityuseUIStreampipeJsonRender + useJsonRenderMessage
Use casePlaygrounds, buildersChatbots, copilots

Learn more in the Generation Modes guide.

Next