Next.js inside ChatGPT's three-layer iframe
OpenAI's Apps SDK with Model Context Protocol (MCP) support lets developers embed interactive web apps directly into ChatGPT conversations. But there's a meaningful gap between serving static HTML in an iframe and running a full Next.js application with client-side navigation, React Server Components, and dynamic routing.
The core obstacle is ChatGPT's security architecture. Apps render inside a three-layer nested iframe structure designed to isolate potentially malicious code from ChatGPT's main interface. The innermost sandbox runs on web-sandbox.oaiusercontent.com, which means a Next.js app thinks that's its origin instead of its real domain.
chatgpt.com
└── web-sandbox.oaiusercontent.com (sandbox iframe)
└── web-sandbox.oaiusercontent.com (inner iframe)
└── your app's HTML
Why the sandbox breaks Next.js
That mistaken origin cascades into eight distinct failures:
Asset requests for paths like
/_next/static/chunks/app.jshit the sandbox domain and return 404s.Every relative URL — images, fonts, API calls — resolves against
web-sandbox.oaiusercontent.cominstead of the app's domain.Browser history entries store full URLs (e.g.,
https://your-app.vercel.app/about), leaking the real domain.Client-side navigation makes fetch requests to the sandbox domain rather than the server.
React Server Component requests are cross-origin and need CORS headers; browsers also send
OPTIONSpreflights that Next.js doesn't handle by default.Parent frames occasionally add attributes to the root
<html>element, causing React hydration mismatches.External links attempt to navigate inside the constrained iframe.
Each requires a distinct patch. The fixes below are implemented in Vercel's starter template, which you can deploy to Vercel immediately.
Pointing assets and relative URLs at the right origin
Next.js's assetPrefix config option forces all /_next/ requests to use a specified origin. Setting it to the app's real URL fixes the first asset-loading problem:
import type { NextConfig } from "next";
import { baseURL } from "./baseUrl";
const nextConfig: NextConfig = {
assetPrefix: baseURL, // Forces /_next/ requests to use your-app.vercel.app
};
export default nextConfig;
The base URL is resolved dynamically so it works across development, preview, and production:
export const baseURL =
process.env.NODE_ENV == "development"
? "<http://localhost:3000>"
: "https://" +
(process.env.VERCEL_ENV === "production"
? process.env.VERCEL_PROJECT_PRODUCTION_URL
: process.env.VERCEL_BRANCH_URL || process.env.VERCEL_URL);
But assetPrefix doesn't cover other relative paths. An HTML <base> element in the root layout solves that more broadly, making every relative URL — for images, stylesheets, and fetch('/api/data') calls — resolve against the app's actual domain:
function NextChatSDKBootstrap({ baseUrl }: { baseUrl: string }) {
return (
<>
<base href={baseUrl}></base>
{/* Other bootstrap code... */}
</>
);
}
Patching the History API and fetch
Next.js calls history.pushState and history.replaceState on every client-side navigation. Inside the sandbox iframe, those calls store absolute URLs that expose the app's real origin. Intercepting both methods and stripping them down to path, search params, and hash keeps navigation history functional while preserving the sandbox boundary:
const originalReplaceState = history.replaceState;
history.replaceState = (state, unused, url) => {
const u = new URL(url ?? "", window.location.href);
const href = u.pathname + u.search + u.hash;
originalReplaceState.call(history, state, unused, href);
};
const originalPushState = history.pushState;
history.pushState = (state, unused, url) => {
const u = new URL(url ?? "", window.location.href);
const href = u.pathname + u.search + u.hash;
originalPushState.call(history, state, unused, href);
};
Client-side navigation also relies on RSC payload fetches. When a user clicks a Link, Next.js fetches the new page's component tree and data. Patching window.fetch rewrites any request targeting the iframe's origin so it goes to the real server instead, with CORS mode enabled:
const appOrigin = new URL(baseUrl).origin;
const isInIframe = window.self !== window.top;
if (isInIframe && window.location.origin !== appOrigin) {
const originalFetch = window.fetch;
window.fetch = (input: URL | RequestInfo, init?: RequestInit) => {
// Parse the request URL from various input types
let url = /* ... parse input to URL ... */;
// If the request targets the iframe's origin, rewrite it
if (url.origin === window.location.origin) {
const newUrl = new URL(baseUrl);
newUrl.pathname = url.pathname;
newUrl.search = url.search;
newUrl.hash = url.hash;
return originalFetch.call(window, newUrl.toString(), {
...init,
mode: "cors", // Enable CORS for cross-origin RSC requests
});
}
return originalFetch.call(window, input, init);
};
}
That patch activates only when two conditions hold: the app runs in an iframe (window.self !== window.top) and the iframe's origin differs from the app's real origin.
Handling CORS preflights in middleware
With fetch patched, navigation requests are now cross-origin. Browsers send OPTIONS preflight requests before cross-origin POSTs, and Next.js uses POST for RSC payloads. Without a response to those preflights, navigation stalls indefinitely.
Middleware runs before every request, making it the right hook for CORS. The middleware below answers OPTIONS with a 204 and adds CORS headers to all other responses:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Handle OPTIONS preflight requests
if (request.method === "OPTIONS") {
const response = new NextResponse(null, { status: 204 });
response.headers.set("Access-Control-Allow-Origin", "*");
response.headers.set(
"Access-Control-Allow-Methods",
"GET,POST,PUT,DELETE,OPTIONS"
);
response.headers.set("Access-Control-Allow-Headers", "*");
return response;
}
// Add CORS headers to all responses
return NextResponse.next({
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
export const config = {
matcher: "/:path*", // Apply to all routes
};
Protecting hydration and external navigation
ChatGPT's parent frames can mutate the root <html> element by adding attributes after server rendering. A MutationObserver watches for those attribute changes and removes unauthorized modifications immediately:
const htmlElement = document.documentElement;
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === "attributes" &&
mutation.target === htmlElement
) {
const attrName = mutation.attributeName;
if (attrName && attrName !== "suppresshydrationwarning") {
htmlElement.removeAttribute(attrName);
}
}
});
});
observer.observe(htmlElement, {
attributes: true,
attributeOldValue: true,
});
Adding suppressHydrationWarning to the <html> tag prevents React from logging mismatch warnings when the parent frame does interfere:
<html lang="en" suppressHydrationWarning>
For external links, ChatGPT exposes an openai.openExternal() API. Intercepting clicks on those links routes them through that API so they open in the user's browser instead of navigating inside the limited iframe:
window.addEventListener(
"click",
(e) => {
const a = (e?.target as HTMLElement)?.closest("a");
if (!a || !a.href) return;
const url = new URL(a.href, window.location.href);
if (
url.origin !== window.location.origin &&
url.origin !== appOrigin
) {
try {
if (window.openai) {
window.openai.openExternal({ href: a.href });
e.preventDefault();
}
} catch {
console.warn("openExternal failed, likely not in OpenAI client");
}
}
},
true // Use capture phase to intercept before Next.js Link components
);
Wiring the MCP server
Once the browser patches are in place, the remaining step is an MCP server that exposes the app to ChatGPT. MCP servers provide two primitives: resources (content ChatGPT can render) and tools (actions the model can invoke).
The homepage HTML is fetched and registered as a resource. The mimeType value text/html+skybridge tells ChatGPT to render it as an interactive widget, and the _meta object holds OpenAI-specific settings like widget description and border visibility:
const html = await getAppsSdkCompatibleHtml(baseURL, "/");
server.registerResource(
"content-widget",
"ui://widget/content-template.html",
{
title: "Show Content",
description: "Displays the homepage content",
mimeType: "text/html+skybridge",
_meta: {
"openai/widgetDescription": "Displays the homepage content",
"openai/widgetPrefersBorder": true,
},
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "text/html+skybridge",
text: `<html>${html}</html>`,
_meta: {
"openai/widgetDescription": "Displays the homepage content",
"openai/widgetPrefersBorder": true,
},
},
],
})
);
Tools are linked to resources via openai/outputTemplate. When ChatGPT invokes the tool, that metadata tells it to render the associated widget afterward:
server.registerTool(
"show_content",
{
title: "Show Content",
description: "Fetch and display the homepage content with the name of the user",
inputSchema: {
name: z.string().describe("The name of the user to display"),
},
_meta: {
"openai/outputTemplate": "ui://widget/content-template.html",
"openai/toolInvocation/invoking": "Loading content...",
"openai/toolInvocation/invoked": "Content loaded",
"openai/widgetAccessible": false,
"openai/resultCanProduceWidget": true,
},
},
async ({ name }) => {
return {
content: [
{
type: "text",
text: name,
},
],
structuredContent: {
name: name,
timestamp: new Date().toISOString(),
},
_meta: {
"openai/outputTemplate": "ui://widget/content-template.html",
"openai/toolInvocation/invoking": "Loading content...",
"openai/toolInvocation/invoked": "Content loaded",
"openai/widgetAccessible": false,
"openai/resultCanProduceWidget": true,
},
};
}
);
The _meta configuration on tools controls the invocation UX:
openai/outputTemplate— points to the registered resource URIopenai/toolInvocation/invoking— text shown while the tool executesopenai/toolInvocation/invoked— text shown after completionopenai/widgetAccessible— marks the widget as keyboard/screen-reader accessibleopenai/resultCanProduceWidget— signals that the tool can render a widget
Receiving data from tool invocations
The app reads data from ChatGPT through window.openai.toolOutput, which ChatGPT populates with the structuredContent from a tool's response. The layout patches the property setter so that when it changes, React state updates reactively:
const [name, setName] = useState<string | null>(null);
useEffect(() => {
if (typeof window === "undefined") return;
if (!window.openai) {
(window as any).openai = {};
}
let currentValue = (window as any).openai.toolOutput;
Object.defineProperty((window as any).openai, "toolOutput", {
get() {
return currentValue;
},
set(newValue: any) {
currentValue = newValue;
if (newValue?.name) {
setName(newValue.name);
}
},
configurable: true,
enumerable: true,
});
if (currentValue?.name) {
setName(currentValue.name);
}
}, []);
React hooks for cleaner ChatGPT wiring
Calling window.openai directly works, but it forces repetitive boilerplate into every component. For anything beyond a demo, we extracted that logic into a set of type-safe React hooks. They handle server-side rendering, control re-renders, and keep the browser API behind a small, predictable surface.
Three hooks cover the common cases. useSendMessage posts follow-up messages to ChatGPT; useWidgetProps reads tool call outputs with full typing; and useDisplayMode adapts your UI to the current presentation context.
app/page.tsx
const sendMessage = useSendMessage();
// Trigger a new ChatGPT message from user interaction
<button onClick={() => sendMessage("Show me more examples")}>
More Examples
</button>
app/page.tsx
const toolOutput = useWidgetProps<{ name?: string }>();
// Access structured data from the tool invocation
const name = toolOutput?.name;
app/page.tsx
const displayMode = useDisplayMode();
// Render different layouts based on how ChatGPT displays the app
return displayMode === "fullscreen" ? <FullView /> : <CompactView />;
The complete set lives in the ChatGPT Apps SDK Next.js Starter repository.
What changes when ChatGPT runs a real Next.js app
Wiring the SDK into a full framework instead of an iframe changes the user experience in several material ways.
Routing and navigation behave like a native app
Users get real client-side navigation. Links move between pages via React Server Components streaming, so transitions are fast and the browser back/forward stack stays intact. Without these patches, you are stuck serving a single static page or stacking another iframe on top—each layer adding latency and complexity.
The whole framework is available, not a subset
Because routing works, there is no reason to restrict what Next.js can do inside ChatGPT. Everything functions normally:
- React Server Components with streaming
- Server Actions for forms
- Incremental Static Regeneration (ISR)
- Dynamic routes with
[slug]patterns - API routes
- Middleware
No special-case development
The patches are applied once in layout.tsx; the rest of the codebase stays ordinary Next.js. You keep using next dev locally and deploying to Vercel without separate build paths or environment tricks.
Performance and feel track a standard site
Client-side navigation transfers only the new page's data, not the full document, so each move is lighter than a reload. Streaming from React Server Components gets content on screen sooner on slow connections. External links open in the real browser, nothing is trapped in nested iframes, and interactions do not suffer from layout glitches.
Starting from a working baseline
The seven patches reconcile ChatGPT's iframe-based hosting with what Next.js expects from the document. The starter template already ships with all of them applied, so you can skip the integration plumbing and write against familiar Next.js patterns. That foundation is enough to build productivity tools, data dashboards, or anything else that benefits from ChatGPT's reach.



