1. What is a smoke test for a ChatGPT App
In the usual web‑development world, a smoke test is a minimal check — “is the system alive at all?” The page opens, buttons don’t crash, and nothing critical is on fire.
In the world of ChatGPT Apps, a smoke test is a bit more interesting because several links are involved in the chain at once:
- Your widget code (React/Next.js).
- Next.js dev server.
- Tunnel (ngrok/Cloudflare).
- ChatGPT, which creates an iframe and loads your widget inside the chat.
For us, a good smoke test is when:
- the widget renders inside ChatGPT without errors;
- basic interactivity works (for example, you click a button — an external link opens);
- neither the browser console nor the dev server logs show a red avalanche of errors.
Important: at this stage we are not yet testing MCP tools, doing load testing, or counting token costs. Our goal is modest and very practical: to prove that the chain “code → Next.js → tunnel → ChatGPT → user” actually closes.
It helps to imagine it as a table like this:
| What we check | How to tell everything is fine |
|---|---|
| Widget rendering | In ChatGPT we see our UI, not a “broken iframe” |
| ChatGPT ↔ our server connection | No “can’t load the app” errors |
| JS running in the sandbox | onClick handlers actually execute |
| Ability to open an external link | A button opens a new tab/window with the given URL |
2. Our training app: a simple “Hello GiftGenius”
In this course we’ll gradually build the GiftGenius app — a gift‑picking assistant. At this step it doesn’t pick anything yet, but it can at least greet politely and show a “learn more” link.
We need a minimal yet honest widget: no complex logic, but real React code.
The simplest version of a widget component can look like this (you can adapt the name and styles, but let’s take the course baseline):
// app/widget/page.tsx
'use client';
export default function GiftGeniusWidget() {
return (
<main style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>
<h1 style={{ fontSize: 24, marginBottom: 8 }}>
Hello from GiftGenius
</h1>
<p style={{ marginBottom: 16 }}>
This is your first ChatGPT App. Next, we'll teach it to pick gifts.
</p>
</main>
);
}
A couple of important points.
First, the 'use client'; directive at the top makes the component a client component. Without it, Next.js treats the file as a server component, and you won’t be able to use window, onClick handlers, or any browser API at all.
Second, it’s a regular React component. There’s no “Apps SDK magic” visible here — and that’s fair. The magic of it ending up inside ChatGPT is hidden in the configuration of the MCP server and the tool that returns a link to the widget’s URL. We’ll handle that later; for now we care only about the UI.
3. Embed the widget into the template and run it
In the official Next.js template for the Apps SDK, the widget page usually already exists; you either edit it or create your own at the needed route (for example, /widget).
Suppose you do have app/widget/page.tsx, and you replace its contents with the code above. The chain then looks like this:
- You save the file.
- The Next.js dev server (already running via npm run dev) reloads the necessary modules, and HMR refreshes the page.
- Through the tunnel, your public HTTPS URL at the same path /widget starts serving the updated UI.
You can verify it in two ways.
First, the old‑fashioned way — in the local browser. Open:
http://localhost:3000/widget
and you’ll see the same Hello from GiftGenius. Yes, this isn’t ChatGPT yet — you’re just making sure that your Next.js app’s UI is alive.
Then — through the tunnel. Take the issued URL (something like https://witty-cat.ngrok-free.app), append /widget, and open it in a regular browser:
https://witty-cat.ngrok-free.app/widget
If all is well, the page should look the same. That means the chain “Next.js → tunnel → your browser” works; all that’s left is to insert ChatGPT between them.
4. Check the widget inside ChatGPT
In Dev Mode, ChatGPT essentially does three steps: it creates an iframe, sets its src to your public URL, and lets that iframe live inside the chat message.
Simplified, the flow looks like this:
sequenceDiagram
participant Dev as You (Dev)
participant Next as Next.js dev server
participant Tun as Tunnel (HTTPS)
participant GPT as ChatGPT
participant User as User
Dev->>Next: npm run dev (http://localhost:3000)
Dev->>Tun: Start tunnel to port 3000
GPT->>Tun: GET https://.../widget
Tun->>Next: Proxy to http://localhost:3000/widget
Next-->>Tun: Widget HTML + JS
Tun-->>GPT: Response with HTML/JS
GPT->>User: Render iframe with widget
To see the result, you:
- Open ChatGPT in the browser, select the needed model (usually GPT‑5.1 or whatever is set by default for Dev Mode).
- Explicitly select your app (via the Apps/Developer menu) or “invoke” it with a phrase like: “Launch the GiftGenius app.”
- ChatGPT calls your app, the MCP server returns a response that includes a link to the UI (that same /widget), and your widget appears in the chat message.
If all goes well, you’ll see the familiar “Hello from GiftGenius” right inside ChatGPT. At this point the smoke test is almost passed: the iframe renders and the “Next.js → tunnel → ChatGPT” chain is alive. One final check remains — that the widget can predictably open an external link. For that, we’ll need openExternal.
A bit later, when you start changing code, a normal dev cycle will look like this:
- Change JSX.
- Save.
- Either refresh the ChatGPT tab or (sometimes) it’s enough to “wiggle” the widget — for example, send a new message or launch the app again (depending on how your template and caching are set up).
If changes aren’t visible, first consider three suspects: the dev server isn’t running, the tunnel dropped, or ChatGPT is connected to an old URL. In the “Where to look for errors if something goes wrong” section we’ll cover this scenario in more detail.
5. Why you can’t just drop in <a href> and forget it
To fulfill the last point of our smoke test — a button that opens an external page — we need to understand openExternal. A reasonable question: “Why do we even need openExternal? What’s wrong with a regular link?”
The problem is that your widget doesn’t live “just in a browser” but in an iframe controlled by ChatGPT. This iframe runs in a fairly strict sandbox: there may be Content Security Policy restrictions, sandbox attributes, oddities with target="_blank", and popup blocking. As a result, the behavior of <ahref="…"> or window.open() inside such an iframe can be unpredictable — from being ignored entirely to showing warnings your code doesn’t control.
In addition, from a UX standpoint OpenAI wants to control when and how you open external pages. That’s why the Apps SDK provides a unified bridge window.openai: your code doesn’t poke the parent window directly but delegates the action to the host application through a well‑defined API.
6. API window.openai.openExternal: what it is and how it works
Inside the widget sandbox there is a global object window.openai. It’s the main “bridge” between your UI and ChatGPT: through it you can call tools, send follow‑up messages, change display modes, control widget state, and of course open external links.
In this lecture we care about one specific method:
window.openai.openExternal({ href: string }): void;
When you call window.openai.openExternal({ href: 'https://example.com' }), ChatGPT:
- Checks that the URL is allowed by policy.
- May show a warning to the user (for example, that this is an external site).
- Opens the link in a new tab/window of the user’s browser.
Two things are important to understand.
First, it’s purely a client‑side operation. It doesn’t call MCP tools, doesn’t hit your backend, and doesn’t spend OpenAI tokens. It’s just a signal to the host application: “please open this URL.”
Second, this approach is compatible with the sandbox. ChatGPT decides by itself exactly how to open the link, without letting your iframe overdo it with window.open().
7. Add a button with openExternal to our widget
Now let’s learn to open an external link from our “Hello GiftGenius.” The simplest scenario: an “Open demo link” button that leads, for example, to your documentation or landing page.
First, we’ll write a small helper so TypeScript doesn’t complain and so the widget doesn’t crash if you happen to open /widget directly in a browser (where window.openai doesn’t exist yet):
// app/widget/openExternalSafe.ts
export function openExternalSafe(href: string) {
if (typeof window !== 'undefined' && (window as any).openai?.openExternal) {
(window as any).openai.openExternal({ href });
} else {
// Fallback for local viewing without ChatGPT
window.open(href, '_blank', 'noopener,noreferrer');
}
}
Here I’m intentionally using (window as any) so we don’t burden you with typing window.openai. A bit later in the course we’ll properly define the interface of this object. For now it’s enough that the code compiles and works.
Now import the helper into our widget and add a button:
// app/widget/page.tsx
'use client';
import { openExternalSafe } from './openExternalSafe';
export default function GiftGeniusWidget() {
return (
<main style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>
<h1 style={{ fontSize: 24, marginBottom: 8 }}>
Hello from GiftGenius
</h1>
<p style={{ marginBottom: 16 }}>
This is your first ChatGPT App. Next, we'll teach it to pick gifts.
</p>
<button
type="button"
onClick={() => openExternalSafe('https://example.com')}
style={{
padding: '8px 16px',
borderRadius: 8,
border: '1px solid #ccc',
cursor: 'pointer',
}}
>
Open demo link
</button>
</main>
);
}
What will happen on click.
If the widget is running inside ChatGPT, window.openai.openExternal exists, and ChatGPT will open https://example.com as required by its rules.
If you opened http://localhost:3000/widget in a regular browser, window.openai doesn’t exist, and the fallback will kick in: a new tab will open using the browser’s standard means. Here window.open is only used when opening /widget directly in a regular browser — i.e., not inside the ChatGPT sandbox. In this context it works as usual and doesn’t cause problems.
We’ll cover openExternal in more detail in module 3 (a separate lecture about the widget and the sandbox), so for now you can safely proceed to running the app.
8. Mini end‑to‑end smoke test
Now you can do a full “battle” run. Try to go through all the steps:
- Make sure the dev server is running (npm run dev) and you see Hello from GiftGenius at http://localhost:3000/widget.
- Make sure the tunnel to port 3000 is up and the public URL opens from an external browser.
- Open ChatGPT, enable Dev Mode, and make sure your app is connected to the correct URL (public, not localhost).
- Open a chat, select the app (or ask the model to launch it).
- Make sure the embedded widget shows “Hello from GiftGenius.”
- Click the “Open demo link” button and make sure the browser opened https://example.com (or your address).
If all that worked, then:
- The widget’s HTML/JS is built correctly and served by the Next server.
- The HTTPS tunnel correctly proxies requests.
- ChatGPT trusts your URL and can load the widget.
- window.openai works and passes the command to open an external link.
That’s exactly what we wanted from the first smoke test.
9. Where to look for errors if something goes wrong
Unlike “regular” frontend, you have three main places for diagnostics. It’s important to quickly understand which one is broken:
- Start with the UI in ChatGPT. If instead of the widget you see an error like “Error loading app” or “We had trouble talking to your app,” the issue is likely in the tunnel or the availability of your dev server. Try opening the public URL directly in a browser: if it doesn’t open or opens with a Next.js error, fix that first.
- Then open the browser DevTools on the tab where ChatGPT is running. There’s a separate iframe for your widget, and inside it — the familiar Console tab. If nothing happens when you click the openExternal button, check whether there are errors like “window.openai is undefined” or other JS errors. If you see that, you’re most likely testing the widget outside ChatGPT (directly via the tunnel URL) or you forgot the 'use client'; directive.
- In parallel, watch the terminal with npm run dev. If build errors are spewing (TypeScript, ESLint, compilation), ChatGPT will at best see an old version of the code, and at worst see nothing. If there are no errors but you don’t see updates, make sure the tunnel is still active: many tunneling services close sessions after idle timeouts.
There’s another common case: everything works on localhost, but when accessing through the tunnel you get 404 or a strange page. Then carefully check the base path (/widget vs /), the basePath/assetPrefix settings (if you’ve touched them already), and the address configured in Dev Mode.
10. A bit about housekeeping: stopping processes
It’s a small thing, but very useful in practice. Beginners often forget that both the dev server and the tunnel are separate processes that continue running in the background.
If you suddenly get “port 3000 is already in use,” it’s possible that an old npm run dev is hiding somewhere deep in your terminals. On Windows this sometimes turns into a “dance” around Task Manager; on macOS and Linux, Ctrl + C in the terminal where the process is running usually saves the day.
Same with the tunnel: if you experimented with multiple tunnels in a row or forgot to close an old one, it’s easy to get confused about which URL your app is currently bound to in Dev Mode. Build a habit: when you’re done with a session — disconnect the tunnel, stop the dev server, and start from a clean slate next time.
11. Common mistakes in the first smoke test
Error #1: using localhost instead of a public HTTPS URL.
A common story: in Dev Mode you accidentally specify http://localhost:3000 or forget about the tunnel altogether. Everything works on your machine, but ChatGPT, living in the cloud, physically cannot reach localhost. The cure is simple: make sure the app settings contain the public HTTPS tunnel address, with the correct path (/mcp or root — depending on the template).
Error #2: forgetting the 'use client'; directive in the widget file.
You write nice React code, add onClick, access window.openai, while Next.js silently makes the page a server component. In the best case you’ll get “window is not defined,” in the worst — the component won’t build at all. To access browser APIs, the widget must be a client component, as indicated by the first line 'use client';.
Error #3: directly calling window.open() instead of openExternal.
It may seem simpler to call window.open('https://example.com'). In a regular browser this might still work, but inside the ChatGPT sandbox you’ll get unpredictable behavior — from being ignored to being blocked. The correct approach for ChatGPT Apps is window.openai.openExternal({ href }), which delegates opening the link to the host and adheres to all security policies.
Error #4: TypeScript complains about window.openai, and the developer “fixes” it by disabling types.
In desperation people sometimes write // @ts-nocheck at the top of the file. That removes compile errors but also turns off TypeScript entirely for that file. It’s much safer either to use a targeted as any around window, or to define a minimal interface for window.openai in a separate file. In this module we chose a small openExternalSafe helper with (window as any), and we’ll add proper typing later.
Error #5: viewing the result only on localhost, not inside ChatGPT.
It’s tempting to stop at the point where http://localhost:3000/widget opens and call it done. But the whole point of this module is to see the app inside ChatGPT. The fact that everything looks fine in a regular browser doesn’t guarantee that ChatGPT will create the iframe properly, pick up resources through the tunnel, and not run into CORS/CSP. A full smoke test always includes a step with actually launching the app in the ChatGPT UI.
Error #6: a forgotten or dropped tunnel.
You updated the code, but ChatGPT shows an old widget version or doesn’t load anything. Often it turns out the tunnel closed due to a timeout, but Developer Mode still points to the old URL. If opening the tunnel URL in a regular browser shows an error — restore the tunnel first, then suspect the Apps SDK.
Error #7: ignoring the console inside the iframe.
Developers used to SPAs check console.log in their app’s DevTools, but inside ChatGPT it’s an iframe, and you need to select the correct frame in DevTools. If you look only at the top level, you might not see any errors even though everything inside the widget has been red for a while. The habit of “opening DevTools for the iframe widget specifically” saves a lot of nerves.