Nitrocloud LogoNitroStack
/sdk
/typescript
/ui
/widgets

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

Typescript
// 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

Typescript
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.

Typescript
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.

Typescript
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.

Typescript
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

Typescript
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.

Typescript
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

Typescript
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

Typescript
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

Typescript
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

Use inline styles for widgets to ensure they work in iframes:

Typescript
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

Typescript
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

Typescript
const { isReady } = useWidgetSDK();

if (!isReady) {
  return <div>Loading...</div>;
}

2. Use TypeScript

Typescript
interface ProductData {
  id: string;
  name: string;
  price: number;
}

const product = getToolOutput<ProductData>();
// TypeScript knows the shape of product

3. Handle Missing Data

Typescript
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

Typescript
const theme = useTheme();

const styles = {
  background: theme === 'dark' ? '#000' : '#fff',
  color: theme === 'dark' ? '#fff' : '#000'
};

5. Provide Example Data in Tools

Typescript
@Tool({
  name: 'get_product',
  examples: {
    response: { id: '1', name: 'Product', price: 99.99 }
  }
})

Debugging Widgets

Test Locally

Bash
cd src/widgets
npm run dev  # Runs on port 3001

Visit: http://localhost:3001/product-card?data={"id":"1","name":"Test"}

Check Studio

Bash
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.

Typescript
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

See Also