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.

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

  1. Parses URL parameters - Extracts data from query string
  2. Handles loading states - Shows loading indicator
  3. Error handling - Displays errors gracefully
  4. 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!