Generative UI in MCP: Building Interactive Next.js Widgets for AI Agent Tools

Text responses are not enough for modern AI apps. NitroStack 1.1 packages Next.js widget preview templates that compile interactive visual cards, dashboards, and diagnostic tables directly into tool outputs.

Hemant Jadhav
Hemant Jadhav
Generative UI in MCP with Interactive Next.js Widgets hero banner with 3D isometric dashboard card

When an AI agent executes a tool on behalf of a user, the standard convention is to return a raw JSON payload or format the data into a markdown string.

If a tool queries a system health endpoint, the agent prints a wall of text:

text
Host: prod-worker-04, CPU: 89%, Memory: 14.2GB/16GB, Status: Degraded, 
Active Alerts: Disk IOPS saturated on /var/log.

While functional, plain text severely limits what developers can build. In modern agentic applications (such as ChatGPT Canvas, Cursor, and NitroChat), users expect Generative UI: interactive bar charts, filterable tables, real-time diagnostic dials, and action buttons.

Returning interactive UI from a backend protocol introduces concrete engineering problems:

  • How do you write components using modern frontend tooling (React, Tailwind CSS, Next.js) without dragging a heavy Node.js rendering runtime into every MCP server replica?
  • How do you deliver compiled frontend assets over a standard JSON-RPC protocol without breaking non-visual terminal clients?
  • How do AI frontends know which widget to mount when a tool returns data?

With NitroStack 1.1, we are introducing native support for Interactive Next.js Widgets bundled directly into CLI presets. In this tutorial, we walk through how widgets are authored in Next.js, how they compile to self-contained HTML bundles, and how tools declare widget bindings.

text
┌─────────────────────────────────────────────────────────────────────────────┐
│                    MCP GENERATIVE UI ARCHITECTURE                           │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   1. Author Component in Next.js                                            │
│      src/widgets/components/SystemHealthCard.tsx (React + Tailwind CSS)     │
│                               │                                             │
│                               ▼                                             │
│   2. Build Time Static Export (next build && static HTML compilation)       │
│      out/system-health.html   (Inlined CSS, zero external JS dependencies)  │
│                               │                                             │
│                               ▼                                             │
│   3. Runtime Tool Invocation (@Tool controller)                             │
│      Returns structured payload + widget binding manifest                   │
│                               │                                             │
│                               ▼                                             │
│   4. Client Rendering (ChatGPT / NitroChat / Web App)                       │
│      Inspects widget manifest ──► Mounts interactive iframe / Shadow DOM    │
│      Passes tool return payload as reactive props to the component          │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

1. Scaffolding with CLI Presets

NitroStack 1.1 introduces two presets configured for generative UI:

  • --preset code-mode: Sandboxed QuickJS with integrated widgets.
  • --preset enterprise-search: BM25 search with visual document preview widgets.

Scaffold a new project using @nitrostack/cli:

bash
npx @nitrostack/cli@1.1.0 init cluster-monitor --preset enterprise-search

Let's inspect the generated directory layout:

text
cluster-monitor/
├── src/
│   ├── app.module.ts
│   ├── health/
│   │   ├── health.controller.ts
│   │   └── health.service.ts
│   └── widgets/
│       ├── package.json
│       ├── tsconfig.json
│       ├── widget-manifest.json
│       ├── pages/
│       │   └── system-health.tsx
│       └── out/
│           └── system-health.html  (Pre-compiled standalone HTML)
└── package.json

The src/widgets directory is an independent Next.js application designed to build self-contained widget artifacts.


2. Authoring an Interactive React Widget

Inside src/widgets/pages/system-health.tsx, you write standard React code with Tailwind CSS:

tsx
import React, { useState } from 'react';

interface SystemHealthProps {
  host: string;
  cpuUsage: number;
  memoryUsage: number;
  status: 'healthy' | 'degraded' | 'critical';
  alerts: string[];
}

export default function SystemHealthWidget({ data }: { data: SystemHealthProps }) {
  const [activeTab, setActiveTab] = useState<'metrics' | 'alerts'>('metrics');

  const statusColor = {
    healthy: 'bg-emerald-500',
    degraded: 'bg-amber-500',
    critical: 'bg-rose-500',
  }[data.status];

  return (
    <div className="w-full max-w-lg rounded-xl border border-slate-800 bg-slate-900 p-5 text-slate-100 shadow-xl">
      <div className="flex items-center justify-between border-b border-slate-800 pb-3">
        <div>
          <h3 className="font-mono text-sm font-bold text-slate-300">{data.host}</h3>
          <p className="text-xs text-slate-500">Real-time Node Telemetry</p>
        </div>
        <div className="flex items-center gap-2">
          <span className={`h-2.5 w-2.5 rounded-full ${statusColor} animate-pulse`} />
          <span className="text-xs font-semibold uppercase tracking-wider">{data.status}</span>
        </div>
      </div>

      <div className="mt-4 flex gap-4 border-b border-slate-800 pb-2">
        <button
          onClick={() => setActiveTab('metrics')}
          className={`text-xs font-bold ${activeTab === 'metrics' ? 'text-sky-400 border-b-2 border-sky-400' : 'text-slate-400'}`}
        >
          Resource Gauges
        </button>
        <button
          onClick={() => setActiveTab('alerts')}
          className={`text-xs font-bold ${activeTab === 'alerts' ? 'text-sky-400 border-b-2 border-sky-400' : 'text-slate-400'}`}
        >
          Alerts ({data.alerts.length})
        </button>
      </div>

      {activeTab === 'metrics' ? (
        <div className="mt-4 space-y-3">
          <div>
            <div className="flex justify-between text-xs font-medium text-slate-400">
              <span>CPU Utilization</span>
              <span>{data.cpuUsage}%</span>
            </div>
            <div className="mt-1 h-2 w-full rounded-full bg-slate-800 overflow-hidden">
              <div className="h-full bg-sky-500" style={{ width: `${data.cpuUsage}%` }} />
            </div>
          </div>
          <div>
            <div className="flex justify-between text-xs font-medium text-slate-400">
              <span>Memory Utilization</span>
              <span>{data.memoryUsage}%</span>
            </div>
            <div className="mt-1 h-2 w-full rounded-full bg-slate-800 overflow-hidden">
              <div className="h-full bg-indigo-500" style={{ width: `${data.memoryUsage}%` }} />
            </div>
          </div>
        </div>
      ) : (
        <div className="mt-4 space-y-2">
          {data.alerts.map((alert, idx) => (
            <div key={idx} className="rounded bg-rose-950/40 border border-rose-800/50 p-2 text-xs text-rose-300">
              {alert}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

3. Compiling to Standalone HTML Bundles

Running a full Next.js Node server alongside your MCP process is an unnecessary operations burden.

Instead, the widget subproject runs a specialized export build:

bash
npm --prefix src/widgets run build

Under the hood, NitroStack compiles each page into a standalone HTML file (out/system-health.html). All CSS classes are inlined, scripts are pre-bundled, and assets are self-contained.

The resulting file weighs less than 45 KB and can be read synchronously by the server without runtime external network calls.

The Widget Manifest

NitroStack generates src/widgets/widget-manifest.json during the build step:

json
{
  "$schema": "https://nitrostack.ai/schemas/widget-manifest.v1.json",
  "version": "1.0.0",
  "widgets": {
    "system-health": {
      "title": "System Health Diagnostic Card",
      "entry": "out/system-health.html",
      "contentType": "text/html",
      "preferredDisplay": "inline-card",
      "defaultProps": {
        "host": "string",
        "cpuUsage": "number",
        "memoryUsage": "number",
        "status": "string",
        "alerts": "array"
      }
    }
  }
}

4. Returning Widgets from @Tool Controllers

To connect your controller to a widget, return the data along with the widget identifier:

typescript
import { Controller, Tool } from '@nitrostack/core';
import { z } from 'zod';

@Controller()
export class ClusterController {
  @Tool({
    name: 'inspect_node_health',
    description: 'Retrieves hardware diagnostics and active alerts for a cluster node',
    schema: z.object({
      nodeId: z.string().describe('Target cluster node ID'),
    }),
  })
  async inspectNode(input: { nodeId: string }) {
    const telemetry = await this.telemetryService.fetchMetrics(input.nodeId);

    return {
      // Raw data structure for LLM reasoning
      nodeId: input.nodeId,
      status: telemetry.status,
      cpuUsage: telemetry.cpu,
      memoryUsage: telemetry.memory,
      alerts: telemetry.activeAlerts,

      // Generative UI metadata envelope
      _widget: {
        id: 'system-health',
        title: `Diagnostics for ${input.nodeId}`,
        props: {
          host: input.nodeId,
          cpuUsage: telemetry.cpu,
          memoryUsage: telemetry.memory,
          status: telemetry.status,
          alerts: telemetry.activeAlerts,
        },
      },
    };
  }
}

5. How Clients Render Generative UI

When an MCP client queries the server, NitroStack exposes widget templates as standard MCP resources:

text
resource://widgets/system-health.html

When the client executes tools/call, it parses the response:

  1. The LLM receives the plain JSON attributes: The model reasons over status: 'degraded' and formulates its textual answer.
  2. The Client UI extracts the _widget envelope: The chat interface loads the pre-compiled template from resource://widgets/system-health.html into a sandboxed iframe or Shadow DOM, injects _widget.props into the component, and renders the live interactive card directly below the agent's message.
  3. Graceful Fallback: If the client is a headless CLI or terminal script that does not support HTML widgets, the _widget field is simply ignored, and the client prints the standard JSON payload.

Conclusion: The Complete NitroStack 1.1 Architecture

With NitroStack 1.1, the platform completes its evolution from a transport-level framework into a comprehensive, end-to-end agentic application platform:

  • Transforms: Flexible pipeline runner with recursive bypass protection.
  • Progressive Discovery: BM25 ranking and catalog browsing to slash prompt bloat by >80%.
  • Sandboxed Code Mode: Sub-millisecond QuickJS WebAssembly execution with strict memory and leak protection.
  • Dynamic Visibility: Session-scoped permissions and reactive list-changed events.
  • Payload Spillover: Automated context protection via virtualized MCP resources.
  • Generative UI: Bundled Next.js widget preview templates for rich agent interfaces.

To start building, install the updated CLI and explore the official templates today:

bash
npm install -g @nitrostack/cli@1.1.0
nitrostack init my-first-app --preset code-mode

About the Author

Hemant Jadhav

Hemant Jadhav

Author