CodeGym /Courses /ChatGPT Apps /Inside the template: project structure and key files

Inside the template: project structure and key files

ChatGPT Apps
Level 2 , Lesson 1
Available

1. Introduction

The ChatGPT App HelloWorld project is not a “magical black box from CodeGym that you’d better not touch.” It is a regular Next.js project; it simply houses, at the same time:

  • a frontend that is rendered inside ChatGPT,
  • an MCP server that responds to tool invocations,
  • settings that glue all this together with ChatGPT.

If you don’t understand where things live, three classic scenarios usually occur:

  1. A developer accidentally writes window in a server file, the app crashes, and they start to hate the entire stack.
  2. They try to add a button to the UI but edit the wrong page.tsx (for example, the app root instead of the widget) and don’t see changes in ChatGPT.
  3. They accidentally put OPENAI_API_KEY in the client side, and the key leaks to the browser.

So today’s goal is to lay out the map: where the UI is, where the MCP is, where the configs are, and where to go when you want to:

  • change the look of the widget,
  • add a new tool,
  • tweak some platform setting (CORS, assetPrefix, etc.).

2. High-level project anatomy

The ChatGPT App HelloWorld Next.js project uses the App Router and is organized around the app/ folder. It combines, in one page tree:

  • the UI of the widget that will be rendered inside ChatGPT,
  • an MCP endpoint that will handle tool calls.

A typical tree (simplified; folder names in your template may differ, but the pattern is the same):

my-chatgpt-app/
├─ app/
│  ├─ api/                          // REST API
│  │  └─ time/                      // GET /api/time returns the server time
│  │     └─ route.ts
│  ├─ hooks/                        // A set of hooks from the official Apps SDK
│  │  ├─ use-call-tool.ts
│  │  ├─ use-display-mode.ts
│  │  └─ use-open-external.ts
│  ├─ mcp/                          // MCP server: ChatGPT calls this when invoking tools
│  │  └─ route.ts
│  ├─ globals.css                   // Root globals.css for the entire application
│  ├─ layout.tsx                    // Root layout for the entire application
│  └─ page.tsx                      // The widget page inside ChatGPT
├─ public/                          // Static assets: icons, manifest, etc.
├─ next.config.ts                   // Next.js config and Apps-specific settings (assetPrefix, etc.)
├─ proxy.ts                         // CORS/headers for working inside an iframe (former middleware.ts)
├─ package.json                     // Project dependencies
├─ tsconfig.json                    // TypeScript configuration
└─ .env.local                       // Secrets: OPENAI_API_KEY, etc.

If there are multiple widgets, they are typically placed not in app/page.tsx but in app/widget/page.tsx. But the logic doesn’t change: there is still one widget page and one endpoint that acts as the MCP server.

It’s convenient to think of your repository as “two-faced Janus”:

  • one “face” is the /mcp path, where ChatGPT goes when it wants to call a tool,
  • the other “face” is the /widget path (or /), which is loaded in an iframe when the model decides to show your UI.

To avoid confusion, let’s fix three groups of files in our heads:

  1. UI layer — everything related to React/Next pages (app/widget, components, styles).
  2. MCP layerapp/mcp/route.ts and the files it uses.
  3. Glue layer and configsnext.config.ts, proxy.ts, .env.local, package.json, tsconfig.json.

We’ll go through each of these layers in a moment.

3. Where the widget lives: the app/widget folder and/or app/page.tsx

Let’s start with what you will touch most often — the widget, i.e., the UI that will be visible inside ChatGPT.

In most current projects you’ll have either:

  • app/widget/page.tsx — the widget lives under a separate /widget prefix,
  • or the root app/page.tsx — the widget coincides with the root page.

Main signs you’ve found the widget file:

  • at the very top it has 'use client', because the component runs in the browser, talks to window and the Apps SDK;
  • it’s a regular React component that renders markup and (a bit later in the course) communicates with window.openai.

The simplest example of a training widget (you may already see something very similar in your project):

// app/widget/page.tsx
'use client';

import React from 'react';

export default function WidgetPage() {
  return (
    <main className="p-4">
      <h1 className="text-xl font-semibold">
        HelloWorld — ChatGPT App
      </h1>
      <p className="text-sm text-gray-500">
        Here we will build our widget's UI.
      </p>
    </main>
  );
}

If your template keeps the widget directly in app/page.tsx, the code will look roughly the same, just without the intermediate widget folder.

Pay attention to a few points.

First, the 'use client' directive is mandatory: the widget reads/writes to window.openai, listens to events, etc., and that is only possible in a client component. If you remove it, Next will try to make the page server-side, and you’ll get errors like “window is not defined.”

Second, it’s a normal, non-magical React component. You can:

  • split it into subcomponents in components/,
  • use Tailwind or any other CSS system,
  • wire up contexts, hooks, etc.

Third, later this is exactly where you will:

  • read window.openai.toolInput and window.openai.toolOutput to render real data,
  • persist widgetState via window.openai.setWidgetState,
  • call openExternal, callTool, and other runtime methods.

For now, it’s enough to know: if you want to change the visual interface — you almost certainly need app/widget/page.tsx or app/page.tsx.

4. Root layout: app/layout.tsx as the “frame” for the whole app

The next important file is app/layout.tsx. It:

  • defines the HTML structure (<html>, <body>),
  • imports global styles (globals.css),
  • often initializes a bootstrap for the Apps SDK (a wrapper that listens to window.openai and passes data into React).

Simplified example:

// app/layout.tsx
import './globals.css';
import type { ReactNode } from 'react';
import { OpenAIAppProvider } from '@/lib/openai-app-provider';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <NextChatSDKBootstrap baseUrl={baseURL} />
      </head>
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-hidden`}>
        {children}
      </body>
    </html>
  );
}

The name NextChatSDKBootstrap here is illustrative; in your template it might be OpenAIAppProvider or another component. Its job is usually the same: set up the connection between the React tree and the Apps SDK runtime, subscribe to global data (theme, displayMode, toolInput, etc.), and distribute them to children.

An important practical takeaway: if you need to connect a global context, styles, or a UI library (for example, shadcn/ui) — the place for it is almost always app/layout.tsx (or a layout inside app/widget for settings and components specific to the widget).

Dissecting NextChatSDKBootstrap

I borrowed NextChatSDKBootstrap from an official template by Vercel. If you didn’t know, they are the folks who created and develop Next. They have a good post about running a ChatGPT App on Next and a Starter Template. Although in a couple of places it’s slightly outdated, I think there’s every chance they’ll keep it current.

Let’s highlight 5 key things that NextChatSDKBootstrap gives us:

  • 1. Fixes hydration issues
    The point is that ChatGPT first loads the HTML of your widget on its server, cleans and patches it. As a result, the hydration mechanism complains and throws warnings in the console. That can prevent you from passing review.
  • 2. Patches browser history
    Your widget is loaded in an iframe from a special domain in ChatGPT. If you try to use your own domain, you’ll break the sandbox. Therefore, only the path without the domain is stored in browser history.
  • 3. Rewrites the fetch() function
    Any fetch() to relative URLs without a domain will not work inside the widget because the iframe is on a different domain. So we override fetch() with our own wrapper that sends requests without a domain to the correct URL. If a domain is specified, everything works unchanged.
  • 4. Clicks on links work
    If links open inside the iframe, ChatGPT will not approve. That’s why code was added to intercept link clicks and open them in an external window via openExternal().
  • 5. Setting head base (DEPRECATED)
    This code also used to add a <base> tag to the <head>, but it no longer works. The sandbox resets any base that you set, so I recommend using absolute links for everything: scripts, resources, fonts, API, etc.

5. MCP server: app/mcp/route.ts

Now let’s move to the second half of “two-faced Janus” — the server that speaks MCP with ChatGPT.

The app/mcp/route.ts file is a regular App Router Route Handler that:

  • accepts HTTP requests from ChatGPT (usually POST with a JSON payload in MCP format),
  • passes them to an MCP server (based on @modelcontextprotocol/sdk or a thin wrapper),
  • returns a JSON response back in MCP format.

There are two options: write it with the raw MCP SDK, or smooth out some edges using a few helper classes from Next/Vercel.

Here’s a version using the plain TypeScript MCP SDK:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

// 1. Create the MCP server
const server = new McpServer({
  name: "simple-mcp-server",
  version: "1.0.0",
});

// 2. Register MCP Resources
// 3. Register MCP Tools

// 4. HTTP transport
const transport = new HttpServerTransport({
  port: 3001,
  path: "/mcp",
});

// 5. Start the server
await server.connect(transport);

But it’s nicer to use a few ready-made classes to improve the developer experience:

// app/mcp/route.ts
import { NextRequest } from 'next/server';
import { createMcpHandler } from "mcp-handler";

const handler = createMcpHandler(async (server) => {
  const gateway = new McpGateway(server);
  await gateway.initialize();
  gateway.registerResources();
  gateway.registerTools();
});

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

Here, McpGateway is a wrapper class around McpServer that you create somewhere (for example, in lib/mcp/server.ts) with the SDK. In our case it all fits into app/mcp/route.ts. Let’s fully break down what’s in this file.

type ContentWidget

At the beginning of the file we define the ContentWidget type. It contains all the widget data and is used in two places: when registering the widget as an mcp-resource and when an mcp-tool returns metadata indicating which widget to use to display the data it returned.

type ContentWidget = {
  id: string;            // Unique name/key
  title: string;         // Title
  description: string;   // Description
  templateUri: string;   // Unique widget URI; can be anything. Does not affect behavior.
  invoking: string;      // Caption above the widget while it is loading
  invoked: string;       // Caption above the widget after it has loaded
  html: string;          // The widget's entire HTML code
  widgetDomain: string;  // The widget's "domain". Does not affect behavior.
};

class McpGateway

A wrapper class around McpServer that simplifies some things. It contains 6 methods:

  • initialize() — here we load our widget’s HTML
  • registerResources() — register widgets as mcp-resources
  • registerTools() — register functions as mcp-tools
  • widgetMeta() — returns widget metadata
  • getAppsSdkCompatibleHtml() — loads the widget’s HTML and lightly patches it
  • makeImgUrlsAbsolute() — patches the HTML: makes image links absolute

Let’s go through them in more detail:

public async initialize()

This method downloads the widgets’ HTML from the internet and fills an object of type ContentWidget.

{
  id: "hello_world",                         // Unique widget key
  templateUri: "ui://widget/hello_world.html", // Unique widget URI. The "ui:" prefix is meaningless.
  title: "HelloWorld Widget",               // Widget name
  description: "Displays the HelloWorld widget", // Explanation for the LLM of what the widget does
  invoking: "Loading widget...",            // Caption above the widget while it loads
  invoked: "Widget loaded",                 // Caption above the widget after it has loaded
  html: htmlWidget,                         // Widget HTML
  widgetDomain: baseURL,                    // The widget's "domain". Currently unused.
}

public registerResources()

Registers widgets as mcp-resources. Calls server.registerResource(), which takes 4 parameters:

  • the MCP resource id/key
  • the resource URI (this is needed by the MCP protocol; for the widget it effectively acts as a unique address)
  • MCP resource metadata
  • a function that returns the MCP resource

Widget metadata

{
  title: widget.title,                 // Resource/widget name
  description: widget.description,     // Resource/widget description
  mimeType: "text/html+skybridge",     // Important! Only this HTML will be rendered as a widget
  _meta: {
    "openai/widgetDescription": widget.description, // Widget description
    "openai/widgetPrefersBorder": true,            // Ask ChatGPT to render a border around the widget
  },
}

The widget as an MCP resource

{
  uri: uri.href,                        // Our URI (taken from the uri parameter)
  mimeType: "text/html+skybridge",      // Important! Only this HTML will be rendered as a widget
  text: widget.html,                    // Widget HTML
  _meta: {
    "openai/widgetDescription": widget.description, // Widget description
    "openai/widgetPrefersBorder": true,            // Ask ChatGPT to render a border around the widget
    "openai/widgetDomain": widget.widgetDomain,    // The widget's "domain". Currently unused.
    "openai/widgetCSP": {                          // Important! Domains accessible to the widget:
      connect_domains: [                           // Domains for connections (fetch, etc.)
        baseURL,
        "https://codegym.cc",
      ],
      resource_domains: [                          // Domains for resources (css/fonts/img)
        baseURL,
        "https://codegym.cc",
        "https://cdn.tailwindcss.com",
        "https://persistent.oaistatic.com",
        "https://fonts.googleapis.com",
        "https://fonts.gstatic.com"
      ]
    }
  },
}

We will revisit openai/widgetCSP more than once, but for now I want to note two things about it:

  • connect_domains — a list of domains for:
    • fetch()
    • loading scripts
    • openExternal()
  • resource_domains — a list of domains for:
    • images
    • CSS
    • fonts

In theory, you can list 200 domains, but whether you can pass review with such a list is a whole different question.

I also studied these parameters in already published apps and found amplitude.com there. That’s good news too. I think solid analytics doesn’t hurt anyone.

public registerTools()

Registers functions as mcp-tools. Calls server.registerTool(), which takes 3 parameters:

  • the MCP tool id/key
  • MCP tool metadata
  • a function that returns the MCP tool

Tool metadata

All parameters in this list are important. I will cover them in more detail in subsequent lectures.

{
  title: widget.title,                               // Tool name
  description: "Returns HelloWorld widget",          // Important! Description of what the tool does
  inputSchema: z.object({}).describe("No inputs"),   // Tool parameter schema. You can use Zod
  _meta: this.widgetMeta(widget),                    // Widget metadata: which widget to display
  annotations: {
    destructiveHint: false,                          // The method does something critical - needs a confirm
    openWorldHint: false,                            // The method changes something in third-party services
    readOnlyHint: true                               // The method changes nothing
  },
}

A function that does something important

async (input, extra) => {
  // 1. Validate parameters
  // 2. Do something important
  return {
    content: [{ type: "text", text: "HelloWorld MCP-tool" }], // Human-readable summary for the AI
    structuredContent: {                                      // Important! This is the JSON result.
      timestamp: new Date().toISOString()                     // It can contain any data.
    },
    _meta: this.widgetMeta(widget),                           // Metadata for the widget that renders the JSON
  };                                                          // It can be omitted — then there will be no widget
}

private widgetMeta(widget: ContentWidget)

Returns widget metadata — ChatGPT uses this to determine which widget to use to display the JSON result.

{
  "openai/outputTemplate": widget.templateUri,            // Widget URI
  "openai/toolInvocation/invoking": widget.invoking,      // Caption above the widget while it is loading
  "openai/toolInvocation/invoked": widget.invoked,        // Caption above the widget after it has loaded
  "openai/widgetAccessible": true,                        // The MCP tool can be invoked from the widget
  "openai/resultCanProduceWidget": true,                  // The MCP tool will return a widget
}

I would like to discuss a simple thing: "openai/outputTemplate". The MCP protocol has three entities (you’ll learn more about them in module 6):

  • MCP Resources
  • MCP Templates
  • MCP Tools

This "openai/outputTemplate" has nothing to do with MCP Templates. MCP Templates are not used at all in ChatGPT Apps. The word “template” here comes from the idea that:

Widgets were conceived as a template for displaying JSON. An MCP tool returns some JSON, the AI renders a widget, passes the JSON via the ToolOutput parameter, and the widget nicely displays that JSON. outputTemplate is simply a synonym for a widget.

I think that’s it for now. We’ll cover these things in more detail in module 4: how exactly to define tools, JSON Schema, and handlers. For now it’s enough to understand: if something is related to tools and logic — look around app/mcp/route.ts.

6. Configuration and “glue”: next.config.ts, middleware.ts, .env and friends

Now let’s break down the main set of files needed for your Next.js project to work correctly inside the ChatGPT iframe and be accessible to ChatGPT via an HTTPS tunnel (ngrok, Cloudflare Tunnel, etc.; we’ll talk about tunnels separately).

next.config.ts

In this file, in addition to standard Next.js settings, you often configure:

  • assetPrefix — so that static assets (JS, CSS from /_next/) are loaded not from ChatGPT’s domain, but from your dev URL (tunnel or Vercel);
  • any template-specific settings (for example, experimental flags for Next 16).

In practice, this looks like a regular export of nextConfig with the required fields. For this lecture, one thing matters: if the widget in ChatGPT cannot load CSS/JS, very often the culprit is assetPrefix.

proxy.ts (formerly middleware.ts)

This file injects a middleware layer between the request coming from ChatGPT and your routes. In the template it typically:

  • sets CORS headers so the ChatGPT iframe is even allowed to talk to your server,
  • sometimes configures additional headers for React Server Components.

You don’t need to know all the intricacies right now. It’s useful just to remember: if ChatGPT complains about CORS or you see strange DevTools errors about access being denied, check proxy.ts.

.env

The .env (or .env.local) file is where secrets and environment parameters live:

  • OPENAI_API_KEY (if your MCP server itself calls the OpenAI API),
  • addresses of your internal APIs,
  • third-party service tokens, etc.

There is an important nuance: in Next.js, variables starting with NEXT_PUBLIC_ automatically end up in the JS bundle and become available in the browser. Never do that with OPENAI_API_KEY; secrets must be server-only variables.

package.json and tsconfig.json

In package.json you will see:

  • versions of Next.js, React, the Apps SDK, the MCP SDK, and other dependencies,
  • scripts like dev, build, start, and sometimes auxiliary commands (linter, formatter, etc.).

In tsconfig.json you’ll find the usual TypeScript settings:

  • path aliases (@/lib, @/components),
  • strict mode,
  • compilation targets.

For this course, the main thing is to understand that the template uses a regular TypeScript stack, and you can extend it in the standard way.

7. A quick “project navigator” for developers

Let’s lock in where to go when you want to do typical tasks. No checklists; just mini-scenarios.

If you want to change text/buttons in the widget, open the UI file of the widget: it’s either app/widget/page.tsx or app/page.tsx — depending on the template. There you edit JSX, add new components, and connect a design system. And this is exactly where you will use the Apps SDK runtime (window.openai or convenient hooks) to display data.

If you need to add a new button that does something on the server, you still start with the UI file. A button in the widget, on click, will call window.openai.callTool, and you’ll add the implementation of that tool in the MCP server configuration, i.e., in code next to app/mcp/route.ts. We’ll break down the UI ↔ tool logic in modules 4 and beyond.

When you want to teach ChatGPT new functionality (for example, “search for tours” or “select products”), go to the MCP layer (the files imported from app/mcp/route.ts). There you register a new tool with a JSON Schema, a description, and a handler. The widget can then read the result via window.openai.toolOutput and display it nicely.

If your static assets break or the widget looks strange only in ChatGPT, while locally everything is fine, remember the glue layer. First check next.config.ts (especially assetPrefix) and middleware.ts/proxy.ts (CORS). If you recently changed the tunnel, URL, or deployed to Vercel, the correctness of these settings is critical.

Finally, if you suspect problems with keys or the environment, your trio of files is .env.local, package.json (to verify which dependencies and scripts are actually used), and your dev server logs. This is the combo that ensures the MCP has access to the necessary secrets and services.

8. Mini practice: get familiar with the filesystem hands-on

Theory is great, but let’s solidify where things are by doing it. You can do these steps right now in your editor/IDE.

Try opening the app folder in your project and find which file is responsible for the widget. If the template uses app/page.tsx, that’s where you’ll see something like “HelloWorld — ChatGPT App” or some welcome text. If there isn’t a separate widget folder, open app/page.tsx and make sure it has 'use client' and some JSX markup.

Next, find app/mcp/route.ts. Note which modules it imports: typically you’ll see either direct usage of the MCP SDK or a call to a helper function from lib/mcp/*. Evaluate how “thin” this layer is — ideally there’s almost no business logic, just “receive JSON → pass to server → return JSON.”

After that, look into next.config.ts and proxy.ts/middleware.ts. You don’t need to understand everything written there, just note that:

  • next.config.ts is responsible for Next configuration, including build rules and asset serving,
  • proxy.ts intervenes in HTTP requests (you’ll almost certainly see it working with headers).

And finally, open .env or .env.local and ensure that your keys live there, not in code. If you see NEXT_PUBLIC_OPENAI_API_KEY anywhere — that’s a great reason to fix it while you’re still in local development.

9. Visual diagram: how ChatGPT interacts with your template

To complete the picture, it’s helpful to look at a simple flow:

flowchart TD
    U[User in ChatGPT] -->|Writes a prompt| M[ChatGPT model]

    M -->|Calls a tool| MCP["Your MCP endpoint
app/mcp/route.ts"] MCP -->|"MCP JSON response (structuredContent, _meta, UI link)"| M M -->|Decides to show UI| WIDGET_URL["Widget URL
(/widget or /)"] WIDGET_URL -->|iframe| W[Your widget
app/page.tsx] W -->|reads window.openai.toolOutput
+ widgetState| U

It’s important to notice that the initiator is almost always the ChatGPT model, not the user’s browser, as in a classic web app. Your app/mcp/route.ts and app/widget/page.tsx are just two different “doors” into the same Next.js project: one for the robot (MCP), the other for the UI.

If you keep this project map in your head (widget → MCP layer → configs) and consciously avoid the listed pitfalls, you can move on in the course focusing on the logic and UX of your app, rather than hunting for “that one file that breaks everything.”

10. Typical mistakes when working with the template structure

Mistake #1: Confusing the widget with a regular site page.
Sometimes a developer sees both app/page.tsx and app/widget/page.tsx in a template, edits the “wrong” file, and wonders why changes don’t appear in ChatGPT. The widget is specifically the page used as the outputTemplate/iframe for an MCP tool. If you change a different route, ChatGPT won’t even know. Always check the template’s README and see which URL is designated as the widget.

Mistake #2: Writing client-side code (window, document) in MCP server files.
The app/mcp/route.ts file and everything it imports runs on the server. Any attempt to use window or DOM APIs there will crash the runtime. If you want to do something in the UI, that almost certainly belongs in files under app/widget or other client components. The MCP layer is pure backend: requests, databases, external APIs, and building a structured response.

Mistake #3: Ignoring assetPrefix and CORS settings.
Everything works great on localhost:3000, but once you open the app through a tunnel in ChatGPT — styles disappear, JS doesn’t load, and the console is full of CORS errors. The cause is often that next.config.ts or middleware.ts/proxy.ts doesn’t account for the new public URL or was accidentally broken during refactoring. When changing these files, always remember that your code will live inside an iframe on ChatGPT’s domain, not directly on localhost.

Mistake #4: Storing secrets outside .env, or in NEXT_PUBLIC_* variables.
Hiding OPENAI_API_KEY in const apiKey = 'sk-...' somewhere in app/widget/page.tsx is the worst idea: the key will end up in the JS bundle and go to any user. Almost as bad is creating a NEXT_PUBLIC_OPENAI_API_KEY variable, because the NEXT_PUBLIC_ prefix guarantees it will land in the browser. Always put secrets in .env without that prefix and use them only on the server side (MCP server, backend functions).

Mistake #5: Assuming the template is “too smart” and being afraid to touch it.
Sometimes developers treat an official starter as something sacred: “better not touch it, I might break the integration.” As a result, they write all their code off to the side, complicate the architecture, and still step on the same rakes. In reality the template is just a neatly assembled Next.js project with a couple of settings for the Apps SDK. Understanding that app/ is UI and MCP, and the rest are ordinary configs, is liberating: you start working with the code like with a familiar React/Next project, not a magic box.

Mistake #6: Trying to solve everything “at the widget level.”
Sometimes you want to do everything in the UI: business logic, database access, external API calls. In the context of ChatGPT Apps that’s an especially bad idea: the widget lives in a very strict sandbox, doesn’t see your secrets, and heavily depends on window.openai. If something serious is needed — it belongs in the MCP layer and backend services; the widget should be a thin presentation layer that displays structured data and, if necessary, triggers tools.

1
Task
ChatGPT Apps, level 2, lesson 1
Locked
Widget environment badge + mini-map of key paths
Widget environment badge + mini-map of key paths
1
Task
ChatGPT Apps, level 2, lesson 1
Locked
Server route /api/time as a "beacon" for understanding app/api
Server route /api/time as a "beacon" for understanding app/api
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION