# Building a ChatGPT App with Next.js 16.1and MCP

I spent the last few days building a ChatGPT App for [Datadef](https://datadef.io). The Model Context Protocol (MCP) is powerful—it basically lets you turn ChatGPT into a host for your own interactive apps—but the documentation is still a bit scattered.

This is a breakdown of what I learned while getting it to work, from the initial setup to the weird debugging hurdles you'll likely hit.

---

## Core Resources

These are the docs I kept open the whole time. They’re the best source of truth right now:

* [**OpenAI Apps SDK Quickstart**](https://developers.openai.com/apps-sdk/quickstart/): Start here.
    
* [**Building an MCP Server**](https://developers.openai.com/apps-sdk/build/mcp-server): The technical details of the protocol.
    
* [**App Submission Guidelines**](https://developers.openai.com/apps-sdk/app-submission-guidelines): Good to read early so you don't have to rewrite things later.
    

---

## How it works: The Architecture

There are two main parts to a ChatGPT App:

1. **Tools:** These are the actions ChatGPT can take. When a user asks for something, ChatGPT sends a JSON request to your `/mcp` endpoint.
    
2. **Resources:** This is the UI. Your tool returns a URI, and ChatGPT fetches the HTML to render it in a sandbox.
    

**The Flow:**

* **User prompt** triggers a tool call.
    
* **Your server** runs the handler and returns `structuredContent` (data for the widget) and `_meta`.
    
* **ChatGPT loads the HTML** (served as `text/html+skybridge`).
    
* **The Widget Runtime** passes the data into your code via `window.openai`.
    

**The Iframe Approach:** The ChatGPT sandbox is very restrictive. To get around this, I serve a tiny HTML wrapper that just loads my "real" app inside an `<iframe>`. This lets me use my normal tech stack while still living inside the ChatGPT interface.

---

## Setup & Tunneling

### Dependencies

The MCP SDK is moving fast, so I'd recommend pinning these versions to avoid breaking changes:

```bash
npm install mcp-handler@1.0.2 @modelcontextprotocol/sdk@1.20.0
```

*Note: There is a Vercel starter out there, but I found it easier to build from scratch since the SDK updates so frequently. (*[https://github.com/vercel-labs/chatgpt-apps-sdk-nextjs-starter](https://github.com/vercel-labs/chatgpt-apps-sdk-nextjs-starter))

### Local Development

ChatGPT needs an HTTPS endpoint to talk to. I use a VPS with nginx and an SSH tunnel to point traffic to my local machine. So basically it does : ChatGPT → Cloudflare redirects DNS like XXXX.domain.com → VPS → Nginx to specific local port → My local machine with a SSH reverse proxy.

**The Tunnel:**

```bash
# Run this locally to map your VPS port to your local dev server
ssh -R 8888:localhost:3000 -N -o ServerAliveInterval=30 user@your-vps-ip
```

### Next.js Config

You need to set specific headers so OpenAI can embed your app. Here’s what my `next.config.js` looks like:

```javascript
async headers() {
  return [
    {
      source: '/mcp',
      headers: [
        { key: 'Access-Control-Allow-Origin', value: '*' },
        { key: 'Access-Control-Allow-Methods', value: 'GET,POST,OPTIONS' },
        { key: 'Access-Control-Allow-Headers', value: '*' },
      ],
    },
    {
      source: '/:path*', 
      headers: [
        { key: 'X-Frame-Options', value: 'ALLOWALL' },
        {
          key: 'Content-Security-Policy',
          value: "frame-ancestors 'self' https://*.openai.com https://*.oaiusercontent.com https://*.web-sandbox.oaiusercontent.com"
        },
      ],
    },
  ];
}
```

## The MCP Route Handler

In Next.js, you'll handle everything in `app/mcp/route.ts`. This is where you define your tools and resources.

```typescript
import { createMcpHandler } from "mcp-handler";
import { z } from "zod";

const handler = createMcpHandler(async (server) => {
  // 1. The UI Resource
  server.registerResource(
    "my-app-ui",
    "ui://widget/view.html",
    {
      title: "App View",
      mimeType: "text/html+skybridge", 
      _meta: {
        "openai/widgetPrefersBorder": true,
        "openai/widgetDescription": "Interactive viewer",
        "openai/widgetDomain": "https://your-app.com",
        "openai/widgetCSP": {
          connect_domains: ["https://api.your-app.com"],
          resource_domains: ["https://static.your-app.com"],
          frame_domains: ["https://your-app.com"], 
        }
      }
    },
    async () => ({
      contents: [{
        uri: "ui://widget/view.html",
        mimeType: "text/html+skybridge",
        text: createIframeWrapper("https://your-app.com/embed"),
      }]
    })
  );

  // 2. The Tool
  server.registerTool(
    "create_something",
    {
      description: "Creates a visualization",
      inputSchema: { prompt: z.string() },
      _meta: {
        "openai/outputTemplate": "ui://widget/view.html",
        "openai/toolInvocation/invoking": "Generating...",
        "openai/toolInvocation/invoked": "Ready!",
        "openai/resultCanProduceWidget": true,
        "openai/widgetAccessible": true,
      }
    },
    async ({ prompt }) => ({
      content: [{ type: "text", text: `Generating: ${prompt}` }],
      structuredContent: { prompt }, 
      _meta: { "openai/outputTemplate": "ui://widget/view.html" }
    })
  );
});

export const GET = handler;
export const POST = handler;
```

---

## The Iframe Wrapper & Widget Runtime

The `createIframeWrapper` function returns the HTML that ChatGPT renders. It grabs the data from ChatGPT and passes it to your app via URL parameters.

### Using `window.openai`

Inside the sandbox, you get a `window.openai` object. This is how you talk to the host:

* `toolOutput`: The data your tool just returned.
    
* `widgetState`: State that persists between turns.
    
* `callTool(name, args)`: Trigger other tools from your UI.
    
* `openExternal(url)`: Open links outside the chat.
    

```javascript
function createIframeWrapper(appUrl) {
  return `
    <!DOCTYPE html>
    <html>
      <body style="margin:0; padding:0; overflow:hidden;">
        <iframe id="frame" style="width:100vw; height:100vh; border:none;"></iframe>
        <script>
          const frame = document.getElementById('frame');
          
          // Get data from OpenAI
          const output = window.openai?.toolOutput || {};
          const prompt = output.structuredContent?.prompt || '';
          frame.src = \`\${appUrl}?prompt=\${encodeURIComponent(prompt)}\`;

          // Listen for updates if the user asks for changes
          window.addEventListener('openai:set_globals', (e) => {
            const newPrompt = e.detail?.globals?.toolOutput?.structuredContent?.prompt;
            if (newPrompt) frame.src = \`\${appUrl}?prompt=\${encodeURIComponent(newPrompt)}\`;
          });
        </script>
      </body>
    </html>
  `;
}
```

---

## Development Workflow

Testing can be slow if you keep starting new chats. Here’s how I sped it up:

1. **Developer Mode:** Enable this in ChatGPT settings to add your local server.
    
2. **The Reload Button:** You don't need a new chat for every change. Just click the **"Reload"** icon on your app in the sidebar to refresh tool definitions.
    
3. **Inspect Element:** You can right-click the widget in ChatGPT and "Inspect" it. This is the only way to see console logs from your wrapper.
    

---

## Common Pitfalls

* **Blank Widgets:** Usually a CSP issue. Check the console for `frame-ancestors` errors.
    
* **CORS Errors:** ChatGPT sends `OPTIONS` requests. If your proxy or server isn't handling preflight, it will fail.
    
* **MIME Types:** If you don't use `text/html+skybridge`, ChatGPT will just show your HTML as a code block.
    
* **Auth:** If your app needs a login, the iframe might get stuck. I usually serve a public "viewer" route and pass a token in the URL.
    

---

## Submission & Launch

When you're ready to submit to the OpenAI App Store, keep these in mind:

### Metadata Hints

OpenAI uses these to decide how to treat your tools:

* `readOnlyHint`: For tools that don't change data.
    
* `openWorldHint`: For tools that touch external APIs.
    
* `destructiveHint`: For tools that delete things (triggers a confirmation).
    

### Requirements

1. **Domain Verification**: You need to host a token at `/.well-known/openai-apps-challenge`.
    
2. **Tool Descriptions**: Be very specific. The model needs to know exactly when to use your tool.
    
3. **Privacy Policy**: You'll need a hosted policy on your domain.
    

---

## Wrapping Up

Building an MCP app is a bit different from standard web dev, but once you get the tunneling and the iframe wrapper working, it opens up a lot of possibilities.

If you're looking for more examples, check out the [Vercel MCP Starter](https://github.com/vercel/mcp-starter-nextjs) or the official [OpenAI docs](https://platform.openai.com/docs/guides/apps/quickstart).

Happy building!

*Theo - Founder of* [*Datadef*](https://datadef.io)
