Skip to content
Dashboard

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

Chief of Software, Vercel

Copy 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

Copy 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

Copy 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);

Copy 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... */}
</>
);
}

Copy 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);
};

Copy 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);
};
}

Copy 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
};

Copy 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>

Copy 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
);

Copy link to headingConnecting your Next.js app to ChatGPT with MCP

Copy 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,
},
},
],
})
);

Copy 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,
},
};
}
);

Copy 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);
}
}, []);

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

Copy link to headingThe advantages this approach unlocks

Copy link to headingNative Next.js navigation

Copy link to headingFull Next.js feature set

Copy link to headingUnchanged developer experience

Copy link to headingPerformance that matches standard Next.js apps

Copy link to headingNative-feeling user experience

Copy 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

Ready to deploy?