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.
Basic Widget
File Structure
src/widgets/
├── app/
│ ├── product-card/
│ │ └── page.tsx ← Your widget
│ ├── layout.tsx
│ └── page.tsx
├── styles/
│ └── ecommerce.ts ← Shared styles
└── package.json
Simple Widget
// src/widgets/app/hello/page.tsx
'use client';
import React from 'react';
export default function HelloWidget({ data }: { data: any }) {
return (
<div style={{
padding: '20px',
backgroundColor: '#000',
color: '#FFD700',
borderRadius: '8px'
}}>
<h2>Hello, {data.name}!</h2>
<p>{data.message}</p>
</div>
);
}
Connecting Widgets to Tools
@Widget Decorator
import { Tool, Widget } from 'nitrostack';
@Tool({
name: 'get_product',
description: 'Get product details',
inputSchema: z.object({
product_id: z.string()
}),
examples: {
response: {
id: 'prod-1',
name: 'Awesome Product',
price: 99.99,
image_url: 'https://example.com/image.jpg'
}
}
})
@Widget('product-card') // ← Links to /widgets/app/product-card/page.tsx
async getProduct(input: any, ctx: ExecutionContext) {
return {
id: input.product_id,
name: 'Awesome Product',
price: 99.99,
image_url: 'https://example.com/image.jpg'
};
}
Widget Component
// src/widgets/app/product-card/page.tsx
'use client';
import React from 'react';
interface ProductData {
id: string;
name: string;
price: number;
image_url: string;
}
export default function ProductCard({ data }: { data: ProductData }) {
return (
<div style={{
backgroundColor: '#000',
color: '#FFF',
padding: '24px',
borderRadius: '12px',
maxWidth: '400px'
}}>
{data.image_url && (
<img
src={data.image_url}
alt={data.name}
style={{
width: '100%',
borderRadius: '8px',
marginBottom: '16px'
}}
/>
)}
<h2 style={{ color: '#FFD700', marginBottom: '8px' }}>
{data.name}
</h2>
<p style={{ fontSize: '24px', fontWeight: 'bold' }}>
${data.price.toFixed(2)}
</p>
</div>
);
}
withToolData HOC
Using the HOC
The withToolData Higher-Order Component from nitrostack/widgets simplifies data fetching:
'use client';
import React from 'react';
import { withToolData } from 'nitrostack/widgets';
interface UserData {
id: string;
name: string;
email: string;
}
function UserProfile({ data }: { data: UserData }) {
return (
<div>
<h2>{data.name}</h2>
<p>{data.email}</p>
</div>
);
}
// Wrap with withToolData for automatic data handling
export default withToolData(UserProfile);
What withToolData Does
- Parses URL parameters - Extracts data from query string
- Handles loading states - Shows loading indicator
- Error handling - Displays errors gracefully
- Auto-refresh - Optionally refreshes data
Manual Data Fetching
If you need custom logic, you can fetch data manually:
'use client';
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
export default function CustomWidget() {
const searchParams = useSearchParams();
const [data, setData] = useState<any>(null);
useEffect(() => {
const dataParam = searchParams.get('data');
if (dataParam) {
try {
setData(JSON.parse(decodeURIComponent(dataParam)));
} catch (error) {
console.error('Failed to parse data:', error);
}
}
}, [searchParams]);
if (!data) return <div>Loading...</div>;
return (
<div>
{/* Render your UI */}
</div>
);
}
Styling Widgets
Inline Styles (Recommended)
Use inline styles for widgets to ensure they work in iframes:
// src/widgets/styles/ecommerce.ts
export const styles = {
container: {
backgroundColor: '#000',
color: '#FFF',
padding: '24px',
borderRadius: '12px',
fontFamily: 'system-ui, sans-serif'
},
heading: {
color: '#FFD700',
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '16px'
},
button: {
backgroundColor: '#FFD700',
color: '#000',
padding: '12px 24px',
borderRadius: '8px',
border: 'none',
fontWeight: 'bold',
cursor: 'pointer'
}
};
Usage:
import { styles } from '../../styles/ecommerce';
export default function MyWidget({ data }: { data: any }) {
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.
Complex Widgets
Product Grid
'use client';
import React from 'react';
import { withToolData } from 'nitrostack/widgets';
import { styles } from '../../styles/ecommerce';
interface Product {
id: string;
name: string;
price: number;
image_url: string;
}
interface ProductGridData {
products: Product[];
pagination: {
page: number;
totalPages: number;
};
}
function ProductsGrid({ data }: { data: ProductGridData }) {
return (
<div style={styles.container}>
<h2 style={styles.heading}>
Products (Page {data.pagination.page} of {data.pagination.totalPages})
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '16px'
}}>
{data.products.map((product) => (
<div
key={product.id}
style={{
backgroundColor: '#1a1a1a',
borderRadius: '8px',
padding: '16px',
border: '1px solid #333'
}}
>
{product.image_url && (
<img
src={product.image_url}
alt={product.name}
style={{
width: '100%',
height: '150px',
objectFit: 'cover',
borderRadius: '4px',
marginBottom: '12px'
}}
/>
)}
<h3 style={{ color: '#FFD700', fontSize: '16px', marginBottom: '8px' }}>
{product.name}
</h3>
<p style={{ fontSize: '20px', fontWeight: 'bold' }}>
${product.price.toFixed(2)}
</p>
</div>
))}
</div>
</div>
);
}
export default withToolData(ProductsGrid);
User Dashboard
'use client';
import React from 'react';
import { withToolData } 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;
}>;
}
function UserDashboard({ data }: { data: DashboardData }) {
return (
<div style={{
backgroundColor: '#000',
color: '#FFF',
padding: '24px',
borderRadius: '12px',
maxWidth: '800px'
}}>
{/* 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={{ color: '#FFD700', marginBottom: '4px' }}>
{data.user.name}
</h2>
<p style={{ color: '#999' }}>{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} />
<StatCard label="Total Spent" value={`$${data.stats.spent}`} />
<StatCard label="Points" value={data.stats.points} />
</div>
{/* Recent Orders */}
<h3 style={{ color: '#FFD700', marginBottom: '16px' }}>Recent Orders</h3>
{data.recentOrders.map((order) => (
<div
key={order.id}
style={{
backgroundColor: '#1a1a1a',
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: '#999' }}>{order.date}</span>
</div>
))}
</div>
);
}
function StatCard({ label, value }: { label: string; value: string | number }) {
return (
<div style={{
backgroundColor: '#1a1a1a',
padding: '20px',
borderRadius: '8px',
textAlign: 'center'
}}>
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#FFD700' }}>
{value}
</div>
<div style={{ color: '#999', marginTop: '8px' }}>{label}</div>
</div>
);
}
export default withToolData(UserDashboard);
Interactive Widgets
Form Widget
'use client';
import React, { useState } from 'react';
import { withToolData } from 'nitrostack/widgets';
function FeedbackForm({ data }: { data: any }) {
const [rating, setRating] = useState(5);
const [comment, setComment] = useState('');
const [submitted, setSubmitted] = useState(false);
const handleSubmit = async () => {
// In production, this would call your MCP tool
console.log('Submitting feedback:', { rating, comment });
setSubmitted(true);
};
if (submitted) {
return (
<div style={{
backgroundColor: '#000',
color: '#FFF',
padding: '24px',
borderRadius: '12px',
textAlign: 'center'
}}>
<h2 style={{ color: '#FFD700' }}>Thank you!</h2>
<p>Your feedback has been submitted.</p>
</div>
);
}
return (
<div style={{
backgroundColor: '#000',
color: '#FFF',
padding: '24px',
borderRadius: '12px',
maxWidth: '400px'
}}>
<h2 style={{ color: '#FFD700', marginBottom: '16px' }}>Rate Your Experience</h2>
{/* Rating */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px' }}>Rating</label>
<input
type="range"
min="1"
max="5"
value={rating}
onChange={(e) => setRating(Number(e.target.value))}
style={{ width: '100%' }}
/>
<div style={{ textAlign: 'center', color: '#FFD700', fontSize: '24px' }}>
{'⭐'.repeat(rating)}
</div>
</div>
{/* Comment */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px' }}>Comment</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
style={{
width: '100%',
backgroundColor: '#1a1a1a',
color: '#FFF',
border: '1px solid #333',
borderRadius: '4px',
padding: '8px'
}}
/>
</div>
{/* Submit */}
<button
onClick={handleSubmit}
style={{
backgroundColor: '#FFD700',
color: '#000',
padding: '12px 24px',
borderRadius: '8px',
border: 'none',
fontWeight: 'bold',
cursor: 'pointer',
width: '100%'
}}
>
Submit Feedback
</button>
</div>
);
}
export default withToolData(FeedbackForm);
Type Generation
Generate Types from Tools
nitrostack generate types
This creates src/widgets/types/tool-data.ts:
// Auto-generated types
export interface GetProductOutput {
id: string;
name: string;
price: number;
image_url: string;
}
export interface BrowseProductsOutput {
products: Array<{
id: string;
name: string;
price: number;
}>;
pagination: {
page: number;
totalPages: number;
};
}
Use in widgets:
import { GetProductOutput } from '../../types/tool-data';
export default function ProductCard({ data }: { data: GetProductOutput }) {
// TypeScript knows the shape of data!
}
Best Practices
1. Use TypeScript
// ✅ Good
interface ProductData {
id: string;
name: string;
price: number;
}
function ProductCard({ data }: { data: ProductData }) {
// ...
}
// ❌ Avoid
function ProductCard({ data }: { data: any }) {
// ...
}
2. Handle Missing Data
// ✅ Good
{data.image_url && (
<img src={data.image_url} alt={data.name} />
)}
// ❌ Avoid
<img src={data.image_url} alt={data.name} /> // Crashes if undefined
3. Use Inline Styles
// ✅ Good
<div style={{ backgroundColor: '#000', color: '#FFF' }}>
// ❌ Avoid
<div className="bg-black text-white"> // Tailwind may not work
4. Provide Example Data
// ✅ Good - Tool has examples
@Tool({
name: 'get_product',
examples: {
response: { id: '1', name: 'Product', price: 99.99 }
}
})
// ❌ Avoid - No examples, widget won't preview
@Tool({ name: 'get_product' })
5. Keep Widgets Simple
// ✅ Good - One widget per tool
@Widget('product-card')
// ❌ Avoid - Complex logic in widget
// Widget should just display, not compute
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 dev # Studio on port 3000
- Navigate to Tools page
- Click "Enlarge" on a tool with a widget
- Check browser console for errors
Next Steps
Pro Tip: Use nitrostack generate types after defining tools to get TypeScript types for your widgets automatically!