UI Widgets Guide
Overview
Widgets are Next.js components that render visual UI for tool and resource responses. They provide rich, interactive displays for data returned by your MCP server. NitroStack provides a modern Widget SDK with React hooks for building powerful, theme-aware widgets.
Quick Start
1. Create a Widget
// src/widgets/app/product-card/page.tsx
'use client';
import { useWidgetSDK } from '@nitrostack/widgets';
interface ProductData {
id: string;
name: string;
price: number;
image_url?: string;
}
export default function ProductCard() {
const { isReady, getToolOutput } = useWidgetSDK();
if (!isReady) {
return <div>Loading...</div>;
}
const product = getToolOutput<ProductData>();
return (
<div style={{
background: '#000',
color: '#fff',
padding: '24px',
borderRadius: '12px'
}}>
{product.image_url && (
<img src={product.image_url} alt={product.name} />
)}
<h2>{product.name}</h2>
<p>${product.price.toFixed(2)}</p>
</div>
);
}
2. Connect to Tool
import { Tool, Widget } from 'nitrostack';
@Tool({
name: 'get_product',
description: 'Get product details',
inputSchema: z.object({
product_id: z.string()
})
})
@Widget('product-card')
async getProduct(input: any, ctx: ExecutionContext) {
return {
id: input.product_id,
name: 'Awesome Product',
price: 99.99,
image_url: 'https://example.com/image.jpg'
};
}
Modern Widget SDK
useWidgetSDK Hook
The primary way to build widgets. Provides access to all SDK functionality.
import { useWidgetSDK } from '@nitrostack/widgets';
export default function MyWidget() {
const {
isReady, // SDK initialization status
getToolOutput, // Get tool response data
callTool, // Call other tools
requestFullscreen, // Display controls
setState, // State management
getTheme // Theme information
} = useWidgetSDK();
if (!isReady) return <div>Loading...</div>;
const data = getToolOutput();
return <div>{data.content}</div>;
}
Theme-Aware Widgets
Use useTheme() to create widgets that adapt to light/dark mode.
import { useWidgetSDK, useTheme } from '@nitrostack/widgets';
export default function ThemedWidget() {
const { isReady, getToolOutput } = useWidgetSDK();
const theme = useTheme();
if (!isReady) return <div>Loading...</div>;
const data = getToolOutput();
const styles = {
background: theme === 'dark' ? '#1a1a1a' : '#ffffff',
color: theme === 'dark' ? '#ffffff' : '#000000',
border: `1px solid ${theme === 'dark' ? '#333' : '#ddd'}`
};
return (
<div style={styles}>
<h2>{data.title}</h2>
<p>{data.description}</p>
</div>
);
}
Responsive Widgets
Use useDisplayMode() to adapt to different display modes.
import { useWidgetSDK, useDisplayMode } from '@nitrostack/widgets';
export default function ResponsiveWidget() {
const { isReady, getToolOutput } = useWidgetSDK();
const displayMode = useDisplayMode();
if (!isReady) return <div>Loading...</div>;
const data = getToolOutput();
const padding = displayMode === 'fullscreen' ? '48px' : '16px';
const fontSize = displayMode === 'fullscreen' ? '24px' : '16px';
return (
<div style={{ padding, fontSize }}>
<h1>{data.title}</h1>
{displayMode === 'fullscreen' && (
<div>Additional details shown in fullscreen</div>
)}
</div>
);
}
Interactive Widgets
Calling Tools from Widgets
import { useWidgetSDK } from '@nitrostack/widgets';
export default function InteractiveWidget() {
const { isReady, getToolOutput, callTool, sendFollowUpMessage } = useWidgetSDK();
if (!isReady) return <div>Loading...</div>;
const data = getToolOutput();
const handleAction = async () => {
const result = await callTool('process_item', { id: data.id });
console.log('Result:', result);
};
const askQuestion = async () => {
await sendFollowUpMessage('Tell me more about this item');
};
return (
<div>
<h2>{data.title}</h2>
<button onClick={handleAction}>Process</button>
<button onClick={askQuestion}>Learn More</button>
</div>
);
}
State Management
Use useWidgetState() for persistent widget state.
import { useWidgetSDK, useWidgetState } from '@nitrostack/widgets';
interface FormState {
name: string;
email: string;
}
export default function StatefulWidget() {
const { isReady, getToolOutput } = useWidgetSDK();
const { state, setState } = useWidgetState<FormState>();
if (!isReady) return <div>Loading...</div>;
const data = getToolOutput();
const updateName = async (name: string) => {
await setState({ ...state, name });
};
return (
<div>
<input
value={state?.name || ''}
onChange={(e) => updateName(e.target.value)}
placeholder="Name"
/>
<p>Current name: {state?.name}</p>
</div>
);
}
Display Controls
import { useWidgetSDK } from '@nitrostack/widgets';
export default function ControlsWidget() {
const {
isReady,
getToolOutput,
requestFullscreen,
requestInline,
requestClose
} = useWidgetSDK();
if (!isReady) return <div>Loading...</div>;
const data = getToolOutput();
return (
<div>
<h2>{data.title}</h2>
<button onClick={requestFullscreen}>Fullscreen</button>
<button onClick={requestInline}>Inline</button>
<button onClick={requestClose}>Close</button>
</div>
);
}
Complex Examples
Product Grid
import { useWidgetSDK, useTheme } from '@nitrostack/widgets';
interface Product {
id: string;
name: string;
price: number;
image_url: string;
}
interface ProductGridData {
products: Product[];
pagination: {
page: number;
totalPages: number;
};
}
export default function ProductsGrid() {
const { isReady, getToolOutput } = useWidgetSDK();
const theme = useTheme();
if (!isReady) return <div>Loading...</div>;
const data = getToolOutput<ProductGridData>();
const containerStyle = {
background: theme === 'dark' ? '#000' : '#fff',
color: theme === 'dark' ? '#fff' : '#000',
padding: '24px',
borderRadius: '12px'
};
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '16px',
marginTop: '16px'
};
const cardStyle = {
background: theme === 'dark' ? '#1a1a1a' : '#f5f5f5',
borderRadius: '8px',
padding: '16px',
border: `1px solid ${theme === 'dark' ? '#333' : '#ddd'}`
};
return (
<div style={containerStyle}>
<h2>Products (Page {data.pagination.page} of {data.pagination.totalPages})</h2>
<div style={gridStyle}>
{data.products.map((product) => (
<div key={product.id} style={cardStyle}>
<img
src={product.image_url}
alt={product.name}
style={{
width: '100%',
height: '150px',
objectFit: 'cover',
borderRadius: '4px',
marginBottom: '12px'
}}
/>
<h3 style={{ fontSize: '16px', marginBottom: '8px' }}>
{product.name}
</h3>
<p style={{ fontSize: '20px', fontWeight: 'bold' }}>
${product.price.toFixed(2)}
</p>
</div>
))}
</div>
</div>
);
}
Dashboard Widget
import { useWidgetSDK, useTheme, useDisplayMode } from '@nitrostack/widgets';
interface DashboardData {
user: {
name: string;
email: string;
avatar?: string;
};
stats: {
orders: number;
spent: number;
points: number;
};
recentOrders: Array<{
id: string;
total: number;
date: string;
}>;
}
export default function UserDashboard() {
const { isReady, getToolOutput } = useWidgetSDK();
const theme = useTheme();
const displayMode = useDisplayMode();
if (!isReady) return <div>Loading...</div>;
const data = getToolOutput<DashboardData>();
const isFullscreen = displayMode === 'fullscreen';
const containerStyle = {
background: theme === 'dark' ? '#000' : '#fff',
color: theme === 'dark' ? '#fff' : '#000',
padding: isFullscreen ? '48px' : '24px',
borderRadius: '12px',
maxWidth: isFullscreen ? '1200px' : '800px'
};
return (
<div style={containerStyle}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '24px' }}>
{data.user.avatar && (
<img
src={data.user.avatar}
alt={data.user.name}
style={{
width: '60px',
height: '60px',
borderRadius: '50%',
marginRight: '16px'
}}
/>
)}
<div>
<h2 style={{ marginBottom: '4px' }}>{data.user.name}</h2>
<p style={{ color: theme === 'dark' ? '#999' : '#666' }}>
{data.user.email}
</p>
</div>
</div>
{/* Stats */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '16px',
marginBottom: '24px'
}}>
<StatCard label="Orders" value={data.stats.orders} theme={theme} />
<StatCard label="Total Spent" value={`$${data.stats.spent}`} theme={theme} />
<StatCard label="Points" value={data.stats.points} theme={theme} />
</div>
{/* Recent Orders */}
<h3 style={{ marginBottom: '16px' }}>Recent Orders</h3>
{data.recentOrders.map((order) => (
<div
key={order.id}
style={{
background: theme === 'dark' ? '#1a1a1a' : '#f5f5f5',
padding: '16px',
borderRadius: '8px',
marginBottom: '12px',
display: 'flex',
justifyContent: 'space-between'
}}
>
<span>Order #{order.id}</span>
<span>${order.total.toFixed(2)}</span>
<span style={{ color: theme === 'dark' ? '#999' : '#666' }}>
{order.date}
</span>
</div>
))}
</div>
);
}
function StatCard({ label, value, theme }: {
label: string;
value: string | number;
theme: 'light' | 'dark' | null;
}) {
return (
<div style={{
background: theme === 'dark' ? '#1a1a1a' : '#f5f5f5',
padding: '20px',
borderRadius: '8px',
textAlign: 'center'
}}>
<div style={{ fontSize: '28px', fontWeight: 'bold' }}>
{value}
</div>
<div style={{
color: theme === 'dark' ? '#999' : '#666',
marginTop: '8px'
}}>
{label}
</div>
</div>
);
}
Styling Widgets
Inline Styles (Recommended)
Use inline styles for widgets to ensure they work in iframes:
const styles = {
container: {
background: '#000',
color: '#fff',
padding: '24px',
borderRadius: '12px',
fontFamily: 'system-ui, sans-serif'
},
heading: {
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '16px'
},
button: {
background: '#007bff',
color: '#fff',
padding: '12px 24px',
borderRadius: '8px',
border: 'none',
fontWeight: 'bold',
cursor: 'pointer'
}
};
export default function StyledWidget() {
const { isReady, getToolOutput } = useWidgetSDK();
if (!isReady) return <div>Loading...</div>;
const data = getToolOutput();
return (
<div style={styles.container}>
<h2 style={styles.heading}>{data.title}</h2>
<button style={styles.button}>Click me</button>
</div>
);
}
Why Not Tailwind?
Tailwind CSS classes may not work in iframes due to CSS scope issues. Use inline styles for widgets.
Utility Functions
Device Detection
import {
isPrimarilyTouchDevice,
isHoverAvailable,
prefersReducedMotion
} from '@nitrostack/widgets';
export default function AdaptiveWidget() {
const { isReady, getToolOutput } = useWidgetSDK();
if (!isReady) return <div>Loading...</div>;
const data = getToolOutput();
const buttonSize = isPrimarilyTouchDevice() ? '48px' : '32px';
const showHoverEffects = isHoverAvailable();
const animate = !prefersReducedMotion();
return (
<button style={{
height: buttonSize,
transition: animate ? 'all 0.3s' : 'none'
}}>
{data.label}
</button>
);
}
Best Practices
1. Always Check isReady
const { isReady } = useWidgetSDK();
if (!isReady) {
return <div>Loading...</div>;
}
2. Use TypeScript
interface ProductData {
id: string;
name: string;
price: number;
}
const product = getToolOutput<ProductData>();
// TypeScript knows the shape of product
3. Handle Missing Data
const data = getToolOutput();
if (!data) {
return <div>No data available</div>;
}
// Safe to use data
return <div>{data.title}</div>;
4. Use Theme for Better UX
const theme = useTheme();
const styles = {
background: theme === 'dark' ? '#000' : '#fff',
color: theme === 'dark' ? '#fff' : '#000'
};
5. Provide Example Data in Tools
@Tool({
name: 'get_product',
examples: {
response: { id: '1', name: 'Product', price: 99.99 }
}
})
Debugging Widgets
Test Locally
cd src/widgets
npm run dev # Runs on port 3001
Visit: http://localhost:3001/product-card?data={"id":"1","name":"Test"}
Check Studio
nitrostack-cli dev # Studio on port 3000
- Navigate to Tools page
- Click "Enlarge" on a tool with a widget
- Check browser console for errors
Legacy Patterns
withToolData HOC
Note: This is the legacy pattern. New widgets should use useWidgetSDK() instead.
import { withToolData } from '@nitrostack/widgets';
function ProductCard({ data }) {
return (
<div>
<h2>{data.name}</h2>
<p>${data.price}</p>
</div>
);
}
export default withToolData(ProductCard);
For migration from withToolData to useWidgetSDK, see the Widget SDK Migration Guide.
Next Steps
- Widget SDK API Reference - Complete API documentation
- Widget SDK Migration Guide - Migrate from withToolData
- Tools Guide - Connect widgets to tools
- Widget Examples Guide - Advanced examples