A ChatGPT Apps SDK application built with Next.js that demonstrates how to create custom UI components that render within ChatGPT conversations.
This project uses a Next.js server that serves dual purposes:
- MCP Server (
app/mcp/route.ts) - Exposes tools and resources to ChatGPT via the Model Context Protocol - React Component Host - Serves the React component HTML that renders inside ChatGPT's iframe
ChatGPT → MCP Tool Call → Next.js MCP Server → Tool Response (structuredContent)
↓
ChatGPT → Resource Request → Next.js Serves HTML → React Component Hydrates
↓
React Component → useWidgetProps() → window.openai.toolOutput → Renders UI
Developer Account Limitations: By default, Strava developer accounts are limited to 1 athlete (your own account). That means to use Strada Infinita with your own Strava account, you'll need to:
-
Generate Strava API Keys: Create API credentials for your Strava account by following the Strava Getting Started guide. Your API keys (Client ID and Client Secret) are available in your Strava API Settings.
-
Host the Application: This application needs to be hosted on your own server. The MCP server must be accessible to ChatGPT, and the React components need to be served from a public URL.
Strada Infinita connects to users' Strava accounts to:
- View Activities: Browse running activities, including distance, pace, elevation, and route maps
- Analyze Performance: Get insights on training patterns, progress over time, and performance metrics
- Interactive Coaching: Your AI assistant can help you understand your training data, suggest improvements, and answer questions about your runs
- Activity Details: Explore detailed information about individual activities, including splits, heart rate zones, and power data (when available)
The app uses Strava's OAuth API to securely authenticate users and access their activity data. All Strava data is fetched on-demand through MCP tools, ensuring your data stays secure and up-to-date.
Tools are registered in app/mcp/route.ts using the createMcpHandler pattern.
Use Zod to define the input schema:
import { z } from "zod";
server.registerTool(
"get_activity",
{
title: "Get Activity",
description: "Fetches a Strava activity by ID",
inputSchema: {
activityId: z.string().describe("The Strava activity ID"),
includeDetails: z.boolean().optional().describe("Include detailed metrics"),
},
_meta: widgetMeta(activityWidget), // Links tool to widget resource
},
async ({ activityId, includeDetails }) => {
// Fetch activity from Strava API
const activity = await stravaClient.getActivity(activityId, includeDetails);
return {
content: [{ type: "text", text: `Retrieved activity: ${activity.name}` }],
structuredContent: activity,
_meta: widgetMeta(activityWidget),
};
}
);The tool handler must return structuredContent in the response. This data is passed to your React component:
async ({ activityId, includeDetails }) => {
const activity = await stravaClient.getActivity(activityId);
return {
content: [{ type: "text", text: `Retrieved activity: ${activity.name}` }],
structuredContent: {
id: activity.id,
name: activity.name,
distance: activity.distance,
movingTime: activity.moving_time,
averagePace: activity.average_speed,
elevation: activity.total_elevation_gain,
startDate: activity.start_date,
// Add any data your component needs
},
_meta: widgetMeta(activityWidget),
};
}Each tool that renders a UI needs a corresponding resource that serves the React component HTML:
const myWidget: ContentWidget = {
id: "my_tool_id",
title: "My Tool",
templateUri: "ui://widget/my-widget.html",
invoking: "Loading...",
invoked: "Loaded",
html: await getAppsSdkCompatibleHtml(baseURL, "/my-component"),
description: "Displays my component",
widgetDomain: "https://example.com",
};
server.registerResource(
"my-widget",
myWidget.templateUri,
{
title: myWidget.title,
description: myWidget.description,
mimeType: "text/html+skybridge",
_meta: {
"openai/widgetDescription": myWidget.description,
"openai/widgetPrefersBorder": true,
},
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "text/html+skybridge",
text: `<html>${myWidget.html}</html>`,
_meta: {
"openai/widgetDescription": myWidget.description,
"openai/widgetPrefersBorder": true,
"openai/widgetDomain": myWidget.widgetDomain,
},
},
],
})
);Key Points:
templateUrimust match theopenai/outputTemplatein tool metadatahtmlis fetched from a Next.js route (e.g.,/my-component)- The resource handler returns the HTML wrapped in
<html>tags
React components are standard Next.js pages that read data from ChatGPT via the window.openai API.
Create a new page in app/activity/page.tsx:
"use client";
import { useWidgetProps } from "@/app/hooks";
export default function ActivityComponent() {
const activity = useWidgetProps<{
id?: string;
name?: string;
distance?: number;
movingTime?: number;
averagePace?: number;
elevation?: number;
startDate?: string;
}>();
if (!activity) return <div>Loading...</div>;
return (
<div>
<h1>{activity.name}</h1>
<p>Distance: {(activity.distance / 1000).toFixed(2)} km</p>
<p>Time: {formatTime(activity.movingTime)}</p>
<p>Pace: {formatPace(activity.averagePace)}</p>
{activity.elevation && <p>Elevation: {activity.elevation}m</p>}
</div>
);
}Use the useWidgetProps() hook to access data from the tool's structuredContent:
const toolOutput = useWidgetProps<MyToolOutputType>();This hook reads from window.openai.toolOutput, which contains the structuredContent returned by your tool.
Use hooks from app/hooks to access other ChatGPT context:
import {
useDisplayMode, // "inline" | "pip" | "fullscreen"
useMaxHeight, // Container max height
useWidgetState, // Persistent widget state
useCallTool, // Call MCP tools from component
useSendMessage, // Send follow-up messages
} from "@/app/hooks";Use useWidgetState() to persist data across user interactions:
const [state, setState] = useWidgetState<{
favoriteActivities: string[];
trainingGoals: { distance: number; targetDate: string }[];
}>({
favoriteActivities: [],
trainingGoals: [],
});
// Update state (automatically persisted and sent to ChatGPT)
setState({
favoriteActivities: [...state.favoriteActivities, activityId],
trainingGoals: state.trainingGoals,
});Important: Widget state is scoped to a single widget instance and is visible to ChatGPT. Keep payloads under 4k tokens.
Components in app/mcp-components/ must follow OpenAI's design guidelines to ensure they feel native to ChatGPT. The layout at app/mcp-components/layout.tsx enforces these constraints.
- Typography: Use system fonts only (inherit platform-native fonts like SF Pro on iOS, Roboto on Android). No custom fonts, even in fullscreen modes.
- Colors:
- Use system colors for text, icons, and spatial elements (dividers, backgrounds)
- Brand colors are only allowed on primary buttons via CSS variables (
--brand-primary) - Do not override text colors or backgrounds with brand colors
- Spacing: Use system grid spacing and maintain consistent padding
- Accessibility: Maintain WCAG AA contrast ratios, provide alt text for images, support text resizing
// Apply brand color to primary buttons
<button data-brand="primary" className="btn-primary">
Action
</button>The brand color CSS variables are defined in app/mcp-components/widgets.css and can be customized per your brand.
Full guidelines: See docs/openai/design-guidelines .md for complete visual design, tone, and interaction guidelines.
-
Tool returns
structuredContent:return { structuredContent: { id: "123456789", name: "Morning Run", distance: 5000, movingTime: 1800, averagePace: 3.6, // m/s elevation: 120, }, };
-
Component reads via
useWidgetProps():const activity = useWidgetProps<{ id: string; name: string; distance: number; movingTime: number; }>(); // activity.name === "Morning Run" // activity.distance === 5000 (meters)
Components can call tools directly using useCallTool():
const callTool = useCallTool();
async function refreshActivity() {
await callTool("get_activity", {
activityId: activity.id,
includeDetails: true
});
}Note: Tools must be marked as component-accessible in the MCP server configuration.
- ChatGPT Apps SDK - Custom UX Guide
- ChatGPT Apps SDK - MCP Server Guide
- Model Context Protocol
- Strava API Documentation
server/
├── app/
│ ├── mcp/
│ │ └── route.ts # MCP server (tools & resources)
│ ├── mcp-components/ # ChatGPT widget components
│ │ ├── layout.tsx # Widget layout (system fonts, SDK bootstrap)
│ │ ├── widgets.css # Widget styles (design guidelines)
│ │ └── [component]/ # Individual widget pages
│ ├── hooks/ # ChatGPT SDK hooks
│ │ ├── use-widget-props.ts
│ │ ├── use-widget-state.ts
│ │ └── ...
│ ├── page.tsx # Website landing page
│ └── layout.tsx # Website layout (custom fonts)
└── middleware.ts # CORS handling