Skip to content
Dashboard

Running Next.js inside ChatGPT: A deep dive into native app integration

Chief of Software, Vercel

Link to headingWhat ChatGPT apps and MCP enable

Ready to build your own ChatGPT app?

Deploy our ChatGPT Apps SDK Next.js starter template to Vercel and start experimenting right away.

Deploy now

The template handles all the browser API patches and MCP server configuration automatically. ChatGPT's nested-iframe architecture breaks several Next.js features. Understanding how the patches fix these issues helps you customize and extend your app beyond the starter.

Link to headingHow ChatGPT's nested-iframe architecture breaks modern frameworks

chatgpt.com
└── web-sandbox.oaiusercontent.com (sandbox iframe)
└── web-sandbox.oaiusercontent.com (inner iframe)
└── your app's HTML

Link to headingMaking static assets load from your actual domain

next.config.ts
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;

baseUrl.ts
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);

Link to headingSetting a base URL for all relative paths

app/layout.tsx
function NextChatSDKBootstrap({ baseUrl }: { baseUrl: string }) {
return (
<>
<base href={baseUrl}></base>
{/* Other bootstrap code... */}
</>
);
}

Link to headingPatching browser history to prevent URL leaks

app/layout.tsx
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);
};

Link to headingRewriting fetch requests for client-side navigation

app/layout.tsx
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);
};
}

Link to headingAdding CORS headers for cross-origin requests

middleware.ts
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
};

Link to headingPreventing parent frame interference with DOM mutations

app/layout.tsx
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,
});

app/layout.tsx
<html lang="en" suppressHydrationWarning>

Link to headingOpening external links in the user's browser

app/layout.tsx
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
);

Link to headingConnecting your Next.js app to ChatGPT with MCP

Link to headingHow MCP resources serve HTML to ChatGPT

app/mcp/route.ts
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,
},
},
],
})
);

Link to headingHow MCP tools trigger app displays

app/mcp/route.ts
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,
},
};
}
);

Link to headingHow your app receives data from ChatGPT

app/page.tsx
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);
}
}, []);

Link to headingUsing React hooks to manage ChatGPT integration

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

Link to headingThe advantages this approach unlocks

Link to headingNative Next.js navigation

Link to headingFull Next.js feature set

Link to headingUnchanged developer experience

Link to headingPerformance that matches standard Next.js apps

Link to headingNative-feeling user experience

Link to headingGetting started with Next.js in ChatGPT

Ready to build your own ChatGPT app?

Deploy our ChatGPT Apps SDK Next.js starter template to Vercel and start experimenting right away.

Deploy now