1. Введение
Проект ChatGPT App HelloWorld — это не «магическая чёрная коробка от CodeGym, в которой лучше ничего не трогать». Это обычный Next.js‑проект, просто в нём одновременно живут:
- фронтенд, который рендерится внутри ChatGPT,
- MCP‑сервер, который отвечает на вызовы инструментов (tools),
- настройки, которые склеивают всё это с ChatGPT.
Если не понимать, где что лежит, обычно происходят три классических сценария:
- Разработчик случайно пишет window в серверном файле, ловит падение и начинает ненавидеть весь стек.
- Пытается добавить кнопку в UI, но правит не тот page.tsx (например, корень приложения, а не виджет) и не видит изменений в ChatGPT.
- Случайно кладёт OPENAI_API_KEY в клиентскую часть, и ключ утекает в браузер.
Поэтому сегодняшняя цель — разметить карту: где UI, где MCP, где конфиги, и куда ходить, когда вы хотите:
- поменять внешний вид виджета;
- добавить новый tool;
- подкрутить какую‑нибудь платформенную настройку (CORS, assetPrefix и т.п.).
2. Высокоуровневая анатомия проекта
Next.js‑проект ChatGPT App HelloWorld использует App Router и организован вокруг папки app/. В нём в одном дереве страниц уживаются:
- UI виджета, который будет рендериться внутри ChatGPT,
- MCP‑endpoint, который будет обрабатывать tool‑вызовы.
Типичное дерево (упрощённое, имена папок в вашем шаблоне могут отличаться, но паттерн тот же):
my-chatgpt-app/
├─ app/
│ ├─ api/ // REST API
│ │ └─ time/ // GET /api/time возвращает время на сервере
│ │ └─ route.ts
│ ├─ hooks/ // Набор хуков из официального Apps SDK
│ │ ├─ use-call-tool.ts
│ │ ├─ use-display-mode.ts
│ │ └─ use-open-external.ts
│ ├─ mcp/ // MCP-сервер: сюда стучится ChatGPT, когда вызывает tools
│ │ └─ route.ts
│ ├─ globals.css // Корневой globals.css всего приложения
│ ├─ layout.tsx // Корневой layout всего приложения
│ └─ page.tsx // Страница виджета внутри ChatGPT
├─ public/ // Статика: иконки, манифест и т.п.
├─ next.config.ts // Конфиг Next.js и Apps-специфичные настройки (assetPrefix и др.)
├─ proxy.ts // CORS/заголовки для работы внутри iframe (бывший middleware.ts)
├─ package.json // Зависимости проекта
├─ tsconfig.json // Конфигурация TypeScript
└─ .env.local // Секреты: OPENAI_API_KEY и др.
Если виджетов несколько, то обычно их кладут не в app/page.tsx, а в app/widget/page.tsx. Но логика от этого не меняется: всё равно есть одна страница‑виджет и один endpoint, который играет роль MCP‑сервера.
Удобно мыслить так: ваш репозиторий — это «двуликий Янус»:
- одно «лицо» — путь /mcp , куда идёт ChatGPT, когда хочет вызвать инструмент;
- другое «лицо» — путь /widget (или /), который загружается в iframe, когда модель решает показать ваш UI.
Чтобы не путаться, зафиксируем в голове три группы файлов:
- UI‑слой — всё, что связано с React/Next‑страницами (app/widget, компоненты, стили).
- MCP‑слой — app/mcp/route.ts и файлы, которые он использует.
- Клеевой слой и конфиги — next.config.ts, proxy.ts, .env.local, package.json, tsconfig.json.
Чуть ниже мы пройдёмся по каждому из этих слоёв.
3. Где живёт виджет: папка app/widget и/или app/page.tsx
Начнём с того, что вы будете трогать чаще всего — виджет, то есть UI, который будет виден внутри ChatGPT.
В большинстве актуальных проектов есть либо:
- папка app/widget/page.tsx — виджет живёт по отдельному префиксу /widget,
- либо корневой app/page.tsx — виджет совпадает с корневой страницей.
Главные признаки файла с виджетом:
- в самом верху стоит 'use client', потому что компонент работает в браузере, общается с window и Apps SDK;
- это обычный React‑компонент, который рендерит разметку и (чуточку позже в курсе) общается с window.openai.
Простейший пример учебного виджета (можете очень похожий уже увидеть у себя в проекте):
// 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">
Здесь мы будем строить UI нашего виджета.
</p>
</main>
);
}
Если в вашем шаблоне виджет лежит прямо в app/page.tsx, код будет примерно таким же, только без промежуточной папки widget.
Обратите внимание на несколько моментов.
Во‑первых, директива 'use client' обязательна: виджет читает/пишет в window.openai, слушает события и т.п., а это возможно только в клиентском компоненте. Если её убрать, Next попробует сделать страницу серверной, и вы получите ошибки вида «window is not defined».
Во‑вторых, это обычный, совсем не магический React‑компонент. Вы можете:
- разбивать его на подкомпоненты в components/,
- использовать Tailwind или любую другую CSS‑систему,
- подключать контексты, хуки и т.д.
Во‑третьих, позже именно здесь вы будете:
- читать window.openai.toolInput и window.openai.toolOutput, чтобы отрисовать реальные данные,
- сохранять widgetState через window.openai.setWidgetState,
- вызывать openExternal, callTool и прочие методы рантайма.
Сейчас достаточно знать: если вы хотите поменять визуальный интерфейс — вам почти наверняка в app/widget/page.tsx или app/page.tsx.
4. Корневой layout: app/layout.tsx как «рамка» для всего приложения
Следующий важный файл — app/layout.tsx. Он:
- определяет HTML‑структуру (<html>, <body>),
- подключает глобальные стили (globals.css),
- часто инициализирует «bootstrap» для Apps SDK (обёртку, которая слушает window.openai и пробрасывает данные в React).
Упрощённый пример:
// 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>
);
}
Название NextChatSDKBootstrap здесь условное, в вашем шаблоне это может быть OpenAIAppProvider или другой компонент. Его задача обычно одна: настроить связь между React‑деревом и рантаймом Apps SDK, подписаться на глобальные данные (theme, displayMode, toolInput и т.д.) и раздать их детям.
Важный практический вывод: если вам нужно подключить глобальный контекст, стили или UI‑библиотеку (например, shadcn/ui) — место для этого почти всегда app/layout.tsx (или layout внутри app/widget для настроек и компонентов, специфичных именно для виджета).
Разбор NextChatSDKBootstrap
NextChatSDKBootstrap я подсмотрел в официальном шаблоне у Vercel. Если вы не знали, то это как раз те ребята, которые создали и развивают Next. У них на сайте есть хороший пост про ChatGPT App на Next. А так же есть Starter Template. Хотя в паре моментов он уже немного устарел, думаю есть все шансы, что они будут поддерживать его актуальность.
Выделим 5 ключевых вещей, который нам дает NextChatSDKBootstrap:
- 1. Фиксит проблемы с гидрацией
Все дело в том, что ChatGPT сначала загружает HTML вашего виджета на свой сервер, подчищает и патчит его. В результате механизм гидрации ругается и сыпет Warnings в консоль. Что может помешать вам пройти review. - 2. Патчит историю браузера
Все дело в том, что ваш виджет загружается в iframe со специального домена в ChatGPT. И если вы будете использвать свой домент, то будете ломать песочницу. Поэтому в историю браузера сохраняется только путь без домена. - 3. Переписывает функцию fetch()
Весь ваш fetch() на относительные адреса без домена не будет работать в виджете, т.к. домен у iframe другой. Поэтому мы подменяем функцию fetch() на свою, которая отправляет запросы без домена на правильный url. Если домен указан, то все работает без изменений. - 4. Клики по ссылкам работают
Если ссылки будут открываться внутри iframe то ChatGPT этого не одобрит. Поэтому был добавлен код, который отслеживает нажатия на ссылки и открывает их во внешней окне через openExternal(). - 5. Установка head base (DEPRECATED)
Так же этот код добавлял <base> в <head>, но он уже не работает. Песочница сбрасывает любой установленный base, так что рекомендую использовать абсолютные ссылки для всего что есть: скрипты, ресуры, шрифты, api и т.п.
5. MCP‑сервер: app/mcp/route.ts
Теперь переходим ко второй половине «двуликого Януса» — серверу, который говорит с ChatGPT по MCP.
Файл app/mcp/route.ts — это обычный Route Handler App Router’а, который:
- принимает HTTP‑запросы от ChatGPT (обычно POST с JSON‑payload в формате MCP),
- передаёт их в MCP‑сервер (на базе @modelcontextprotocol/sdk или тонкой обёртки),
- возвращает обратно JSON‑ответ в формате MCP.
Есть два варианта: можно написать на голом MCP SDK, а можно попробовать сгладить углы и использовать несколько классов от того же Next/Vercel.
Вот вариант на чистом TS MCP SDK:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// 1. Создаём MCP сервер
const server = new McpServer({
name: "simple-mcp-server",
version: "1.0.0",
});
// 2. Регистрируем MCP Resources
// 3. Регистрируем MCP Tools
// 4. HTTP транспорт
const transport = new HttpServerTransport({
port: 3001,
path: "/mcp",
});
// 5. Запуск сервера
await server.connect(transport);
Но лучше использовать несколько готовых классов, чтобы работать было приятнее:
// 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;
Здесь McpGateway — это класс-обертка вокруг McpServer, который вы где‑то создаёте (например, в lib/mcp/server.ts) с помощью SDK. В нашем случае он весь помещается в app/mcp/route.ts. Давайте полность разберем, что находится в этом файле.
type ContentWidget
В начале файла у нас описан тип ContentWidget. Он содержит все данные виджета и используется в двух местах: при регистрации виджета как mcp-resource и когда mcp-tool возвращает metadata, где указывает какой виджет использовать для отображения данных, которые он вернул.
type ContentWidget = {
id: string; // Уникальное имя/key
title: string; // Title
description: string; // Description
templateUri: string; // Уникальный URI виджета, может быть любым. Ни на что не влияет.
invoking: string; // Надпись над виджетом пока он грузится
invoked: string; // Надпись над виджетом когда он уже загрузился
html: string; // Весь html код виджета.
widgetDomain: string; // "Домен" виджета. Ни на что не влияет.
};
class McpGateway
Класс-обертка над McpServer, упрощает некоторые вещи. Содержит 6 методов:
- initialize() — тут мы загружаем HTML нашего виджета
- registerResources() — регистрируем виджеты как mcp-resources
- registerTools() — регистрируем функции как mcp-tools
- widgetMeta() — возвращает метаданные виджета
- getAppsSdkCompatibleHtml() — загружает html код виджета и немного патчит его
- makeImgUrlsAbsolute() — патчит html: меняет ссылки на картинки на абсолютные
Давайте пройдемся по ним подробнее:
public async initialize()
Этот метод загружает из интернета html-код виджетов и заполняет объект типа ContentWidget.
{
id: "hello_world", // Уникальный key виджета
templateUri: "ui://widget/hello_world.html", // Уникальный URI виджета. "ui:" ничего не значит.
title: "HelloWorld Widget", // Имя виджета
description: "Displays the HelloWorld widget", // Объяснение для LLM что делает виджет
invoking: "Loading widget...", // Надпись над виджетом во время его загрузки
invoked: "Widget loaded", // Надпись над виджетом после его загрузки
html: htmlWidget, // HTML виджета
widgetDomain: baseURL, // "Домен" виджета. Сейчас ни на что не влияет.
}
public registerResources()
Регистрирует виджеты как mcp-resources. Вызывает метод server.registerResource(), в который передаеются 4 параметра:
- id/key MCP-ресурса
- URI ресурса (это нужно именно для MCP-протокола, для виджета фактически синоним уникального адреса)
- Метаданные MCP-ресурса
- Функция, которая возвращает MCP-ресурс
Метаданные виджета
{
title: widget.title, // Имя ресурса/виджета
description: widget.description, // Описание ресурса/виджета
mimeType: "text/html+skybridge", // Важно! Только такой html будет отображаться как виджет
_meta: {
"openai/widgetDescription": widget.description, // Описание виджета
"openai/widgetPrefersBorder": true, // Говорим ChatGPT отрисовать рамку виджета
},
}
Виджет как MCP-ресурс
{
uri: uri.href, // Наш URI (берется из параметра uri)
mimeType: "text/html+skybridge", // Важно! Только такой html будет отображаться как виджет
text: widget.html, // HTML виджета
_meta: {
"openai/widgetDescription": widget.description, // Описание виджета
"openai/widgetPrefersBorder": true, // Говорим ChatGPT отрисовать рамку виджета
"openai/widgetDomain": widget.widgetDomain, // "Домен" виджета. Сейчас ни на что не влияет.
"openai/widgetCSP": { // Важно! Домены доступные виджету:
connect_domains: [ // Домены для соединений (fetch и т.п.)
baseURL,
"https://codegym.cc",
],
resource_domains: [ // Домены для ресурсов (css/fonts/img)
baseURL,
"https://codegym.cc",
"https://cdn.tailwindcss.com",
"https://persistent.oaistatic.com",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com"
]
}
},
}
В будущем мы еще не раз коснемся openai/widgetCSP, но сейчас хотелось бы отметить 2 момента насчет него:
- connect_domains — список доментов для:
- fetch()
- загрузки скриптов
- openExternal()
- resource_domains — список доментов для:
- картинок
- CSS
- шрифтов
Теоретически вы можете написать 200 доменов, но вот сможете ли вы с таким списком пройти review — это еще тот вопрос.
Так же я изучил эти параметры у уже опубликованных приложений и нашел там amplitude.com. Что тоже позитивная новость. Думаю хорошая аналитика никому не помешает.
public registerTools()
Регистрирует функции как mcp-tools. Вызывает метод server.registerTool(), в который передаеются 3 параметра:
- id/key MCP-tool
- Метаданные MCP-tool
- Функция, которая возвращает MCP-tool
Метаданные инструмента
Все параметры в этом списке важны. Подробнее о них я расскажу в следующих лекциях.
{
title: widget.title, // Имя инструмента
description: "Returns HelloWorld widget", // Важно! Описание что инструмент делает
inputSchema: z.object({}).describe("No inputs"), // Схема параметров инструмента. Можно Zod
_meta: this.widgetMeta(widget), // Метаданные виджета: какой виджет отображать
annotations: {
destructiveHint: false, // Метод делает что-то важное - нужен confirm
openWorldHint: false, // Метод меняет что-то в third party services
readOnlyHint: true // Метод ничего не меняет
},
}
Функция которая делает что-то важное
async (input, extra) => {
// 1. Валидация параметров
// 2. Делаем что-то важное
return {
content: [{ type: "text", text: "HelloWorld MCP-tool" }], // Описание результата для ИИ
structuredContent: { // Важно! Это и есть JSON результата.
timestamp: new Date().toISOString() // Может содержать любые данные.
},
_meta: this.widgetMeta(widget), // Метаданные виджета, который отображает JSON
}; // Может отсутствовать - тогда виджета не будет
}
private widgetMeta(widget: ContentWidget)
Возвращает метаданные виджета — по ним ChatGPT определит какой виджет использовать для отображения JSON-результата.
{
"openai/outputTemplate": widget.templateUri, // URI виджета
"openai/toolInvocation/invoking": widget.invoking, // Надпись над виджетом, пока он грузится
"openai/toolInvocation/invoked": widget.invoked, // Надпись над виджетом, когда он загрузился
"openai/widgetAccessible": true, // MCP-tool можно вызывать из виджета
"openai/resultCanProduceWidget": true, // MCP-tool вернет виджет
}
Отдельно хотелось бы обговорить такую простую вещь как "openai/outputTemplate". В MCP-протоколе есть 3 сущности (о которых вы подробнее узнаете в модуле 6):
- MCP Resources
- MCP Templates
- MCP Tools
Так вот, данный "openai/outputTemplate" не имеет никакого отношения к MCP Templates. MCP Templates вообще никак не используются в ChatGPT Apps. Слово template тут вот откуда:
Виджеты задумывались как шаблон для отображения JSON. MCP-tool вернул некий JSON, ИИ отобразил виджет, передал ему JSON через параметр ToolOutput, и виджет красиво отображает этот JSON. outputTemplate — это просто синоним виджета.
Думаю на этом все. Подробнее мы разберем эти вещи в модуле 4: как именно описывать инструменты, JSON Schema и обработчики. Сейчас достаточно понимать: если что‑то связано с инструментами (tools) и логикой — ищите рядом с app/mcp/route.ts.
6. Конфигурация и «клей»: next.config.ts, middleware.ts, .env и друзья
Теперь разберём главный набор файлов, которые нужны, чтобы ваш Next.js‑проект корректно работал внутри iframe ChatGPT и был доступен ChatGPT через HTTPS‑туннель (ngrok, Cloudflare Tunnel и т.п.; про туннели мы ещё поговорим отдельно).
next.config.ts
В этом файле, помимо стандартных настроек Next.js, часто настраивается:
- assetPrefix — чтобы статика (JS, CSS из /_next/) корректно грузилась не с домена ChatGPT, а с вашего dev‑URL (туннеля или Vercel);
- любые специфичные настройки, нужные шаблону (например, экспериментальные флаги под Next 16).
На практике это выглядит как обычный экспорт nextConfig с нужными полями. Для лекции нам важно одно: если в ChatGPT виджет не может загрузить CSS/JS, очень часто виноват именно assetPrefix.
proxy.ts (бывший middleware.ts)
Этот файл вставляет слой middleware между запросом из ChatGPT и вашими роутами. В шаблоне он обычно:
- выставляет CORS‑заголовки, чтобы iframe ChatGPT вообще имел право обращаться к вашему серверу;
- иногда настраивает дополнительные заголовки для React Server Components.
Знать все тонкости сейчас не обязательно. Полезно лишь помнить: если ChatGPT жалуется на CORS или вы видите странные ошибки в DevTools про запреты доступа, загляните в proxy.ts.
.env
Файл .env (или .env.local) — место для секретов и параметров окружения:
- OPENAI_API_KEY (если MCP‑сервер сам ходит в OpenAI API),
- адреса ваших внутренних API,
- токены сторонних сервисов и т.д.
Есть важный нюанс: в Next.js переменные, начинающиеся с NEXT_PUBLIC_, автоматически попадают в JS‑бандл и становятся доступными в браузере. Никогда не делайте так с OPENAI_API_KEY; секреты должны быть только серверными переменными.
package.json и tsconfig.json
В package.json вы увидите:
- версии Next.js, React, Apps SDK, MCP SDK и прочих зависимостей;
- скрипты dev, build, start, а иногда и вспомогательные команды (линтер, форматер и т.д.).
В tsconfig.json лежат привычные для вас настройки TypeScript:
- пути алиасов (@/lib, @/components),
- строгий режим,
- таргеты компиляции.
С точки зрения этого курса главное — понимать, что шаблон использует обычный TypeScript‑стек, и вы можете расширять его стандартным образом.
7. Быстрый «навигатор по проекту» для разработчика
Давайте зафиксируем, куда идти, когда вы хотите сделать типичные вещи. Без списков, просто в виде мини‑сценариев.
Если вам хочется поменять текст/кнопки в виджете, вы открываете файл c UI виджета: это либо app/widget/page.tsx, либо app/page.tsx — в зависимости от шаблона. Там вы правите JSX, добавляете новые компоненты, подключаете дизайн‑систему. И именно здесь вы будете использовать Apps SDK‑рантайм (window.openai или удобные хуки) для отображения данных.
Если нужно добавить новую кнопку, которая что‑то делает на сервере, вы начинаете всё равно с UI‑файла. Кнопка в виджете по клику будет вызывать window.openai.callTool, а реализацию этого инструмента вы добавите в конфигурацию MCP‑сервера, то есть в код рядом с app/mcp/route.ts. Связку UI ↔ tool‑логика как раз будем разбирать в модулях 4 и далее.
Когда вы хотите научить ChatGPT новый функционал (например, «поиск туров» или «подбор товаров»), вы идёте в MCP‑слой (файлы, импортируемые из app/mcp/route.ts). Там регистрируете новый tool с JSON Schema, описанием и обработчиком. Виджет может потом читать результат через window.openai.toolOutput и красиво его отображать.
Если у вас слетела статика или виджет странно отображается только в ChatGPT, а локально всё в порядке, вспоминаем про клеевой слой. В первую очередь стоит проверить next.config.ts (особенно assetPrefix) и middleware.ts/proxy.ts (CORS). Если вы недавно меняли туннель, URL или деплоили на Vercel, то корректность этих настроек критична.
Наконец, если вы подозреваете проблемы с ключами или окружением, ваша тройка файлов — .env.local, package.json (чтобы убедиться, какие зависимости и скрипты реально используются) и логи dev‑сервера. Именно эта связка отвечает за то, чтобы MCP имел доступ к нужным секретам и сервисам.
8. Мини‑практика: знакомимся с файловой системой руками
Теория теорией, но давайте руками зафиксируем, где что лежит. Эти шаги можно проделать прямо сейчас в редакторе/IDE.
Попробуйте открыть в своём проекте папку app и найти, какой файл отвечает за виджет. Если шаблон использует app/page.tsx, именно там вы увидите знакомую надпись вроде «HelloWorld — ChatGPT App» или приветственный текст. Если виджета как отдельной папки нет, откройте app/page.tsx и убедитесь, что там есть 'use client' и какая‑то JSX‑разметка.
Дальше найдите app/mcp/route.ts. Обратите внимание, какие модули он импортирует: обычно вы увидите либо прямое использование MCP SDK, либо вызов вспомогательной функции из lib/mcp/*. Оцените, насколько «тонкой» сделана эта прослойка — в идеале там почти нет бизнес‑логики, только «принял JSON → передал серверу → вернул JSON».
После этого загляните в next.config.ts и proxy.ts/middleware.ts. Не нужно понимать всё, что там написано, просто зафиксируйте, что:
- next.config.ts отвечает за конфигурацию Next, в том числе за правила сборки и отдачи ассетов;
- proxy.ts вмешивается в HTTP‑запросы (почти наверняка увидите там работу с заголовками).
И напоследок откройте .env или .env.local и удостоверьтесь, что ваши ключи лежат именно там, а не в коде. Если где‑то увидите NEXT_PUBLIC_OPENAI_API_KEY — это отличный повод исправить, пока речь ещё только о локальной разработке.
9. Визуальная схема: как ChatGPT взаимодействует с вашим шаблоном
Чтобы картина окончательно сложилась, полезно взглянуть на простой поток:
flowchart TD
U[Пользователь в ChatGPT] -->|Пишет запрос| M[Модель ChatGPT]
M -->|Вызывает tool| MCP["Ваш MCP endpoint
app/mcp/route.ts"]
MCP -->|"JSON-ответ MCP(structuredContent, _meta, UI-ссылка)"| M
M -->|Решает показать UI| WIDGET_URL["URL виджета
(/widget или /)"]
WIDGET_URL -->|iframe| W[Ваш виджет
app/page.tsx]
W -->|читает window.openai.toolOutput
+ widgetState| U
Здесь важно заметить, что инициатором почти всегда является модель ChatGPT, а не браузер пользователя, как в классическом веб‑приложении. Ваш app/mcp/route.ts и app/widget/page.tsx — это просто две разные «двери» в один и тот же Next.js‑проект: одна для робота (MCP), другая для UI.
Если держать в голове эту карту проекта (виджет → MCP‑слой → конфиги) и осознанно избегать перечисленных граблей, дальше по курсу вы сможете сосредоточиться уже на логике и UX своего App, а не на поиске «того самого файла, который всё ломает».
10. Типичные ошибки при работе со структурой шаблона
Ошибка №1: Путать виджет с обычной страницей сайта.
Иногда разработчик видит в шаблоне и app/page.tsx, и app/widget/page.tsx, правит «не тот» файл и удивляется, почему изменения не появляются в ChatGPT. Виджет — это именно та страница, которая используется как outputTemplate/iframe для MCP‑инструмента. Если вы меняете другой роут, ChatGPT об этом даже не узнает. Всегда сверяйтесь с README шаблона и смотрите, какой URL указан как виджет.
Ошибка №2: Писать клиентский код (window, document) в серверных файлах MCP.
Файл app/mcp/route.ts и всё, что он импортирует, выполняется на сервере. Любая попытка использовать там window или DOM‑API приведёт к падающему рантайму. Если хочется что‑то сделать в UI, это почти наверняка должно быть в файлах под app/widget или других клиентских компонентах. MCP‑слой — чистый backend: запросы, базы, внешние API и формирование структурированного ответа.
Ошибка №3: Игнорировать assetPrefix и CORS‑настройки.
На локальном localhost:3000 всё работает прекрасно, но стоит открыть App через туннель в ChatGPT — и стили пропадают, JS не грузится, в консоли куча CORS‑ошибок. Часто причина в том, что конфигурация next.config.ts или middleware.ts/proxy.ts не учитывает новый публичный URL или была случайно сломана при рефакторинге. Меняя эти файлы, всегда держите в голове, что ваш код будет жить внутри iframe на домене ChatGPT, а не напрямую на localhost.
Ошибка №4: Хранить секреты не в .env, а прямо в коде или в NEXT_PUBLIC_*‑переменных.
Прятать OPENAI_API_KEY в const apiKey = 'sk-...' где‑нибудь в app/widget/page.tsx — это худшая из идей: ключ окажется в JS‑бандле и уйдёт к любому пользователю. Почти так же плохо — сделать переменную NEXT_PUBLIC_OPENAI_API_KEY, потому что префикс NEXT_PUBLIC_ гарантирует попадание в браузер. Всегда кладите секреты в .env без этого префикса и используйте их только на серверной стороне (MCP‑сервер, backend‑функции).
Ошибка №5: Считать шаблон «слишком умным» и бояться его трогать.
Иногда разработчики относятся к официальному стартеру как к чему‑то священному: «лучше туда не лезть, вдруг сломаю интеграцию». В результате они пишут весь свой код где‑то сбоку, усложняют архитектуру и всё равно наступают на те же грабли. На самом деле шаблон — это всего лишь аккуратно собранный Next.js‑проект с парой настроек под Apps SDK. Понимание, что app/ — это UI и MCP, а остальное — обычные конфиги, очень освобождает: вы начинаете работать с кодом как с привычным React/Next‑проектом, а не с магической коробкой.
Ошибка №6: Пытаться решить все проблемы «на уровне виджета».
Иногда хочется всё делать в UI: и бизнес‑логику, и доступ к базам, и внешние API‑запросы. В контексте ChatGPT Apps это особенно плохая идея: виджет живёт в очень жёсткой песочнице, не видит ваши секреты и сильно зависит от window.openai. Если нужно что‑то серьёзное — место этому в MCP‑слое и backend‑сервисах, а виджет должен быть тонким презентационным слоем, который отображает структурированные данные и, при необходимости, триггерит инструменты.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ