
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:
textHost: 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:
bashnpx @nitrostack/cli@1.1.0 init cluster-monitor --preset enterprise-search
Let's inspect the generated directory layout:
textcluster-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:
tsximport 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:
bashnpm --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:
typescriptimport { 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:
textresource://widgets/system-health.html
When the client executes tools/call, it parses the response:
- The LLM receives the plain JSON attributes: The model reasons over
status: 'degraded'and formulates its textual answer. - The Client UI extracts the
_widgetenvelope: The chat interface loads the pre-compiled template fromresource://widgets/system-health.htmlinto a sandboxed iframe or Shadow DOM, injects_widget.propsinto the component, and renders the live interactive card directly below the agent's message. - Graceful Fallback: If the client is a headless CLI or terminal script that does not support HTML widgets, the
_widgetfield 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:
bashnpm install -g @nitrostack/cli@1.1.0 nitrostack init my-first-app --preset code-mode
About the Author
