JavaRush /Курсы /ChatGPT Apps /Как устроен шаблон: структура проекта и ключевые файлы

Как устроен шаблон: структура проекта и ключевые файлы

ChatGPT Apps
2 уровень, 1 лекция
Открыта

1. Введение

Проект ChatGPT App HelloWorld — это не «магическая чёрная коробка от CodeGym, в которой лучше ничего не трогать». Это обычный Next.js‑проект, просто в нём одновременно живут:

  • фронтенд, который рендерится внутри ChatGPT,
  • MCP‑сервер, который отвечает на вызовы инструментов (tools),
  • настройки, которые склеивают всё это с ChatGPT.

Если не понимать, где что лежит, обычно происходят три классических сценария:

  1. Разработчик случайно пишет window в серверном файле, ловит падение и начинает ненавидеть весь стек.
  2. Пытается добавить кнопку в UI, но правит не тот page.tsx (например, корень приложения, а не виджет) и не видит изменений в ChatGPT.
  3. Случайно кладёт 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.

Чтобы не путаться, зафиксируем в голове три группы файлов:

  1. UI‑слой — всё, что связано с React/Next‑страницами (app/widget, компоненты, стили).
  2. MCP‑слойapp/mcp/route.ts и файлы, которые он использует.
  3. Клеевой слой и конфиги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‑сервисах, а виджет должен быть тонким презентационным слоем, который отображает структурированные данные и, при необходимости, триггерит инструменты.

1
Задача
ChatGPT Apps, 2 уровень, 1 лекция
Недоступна
Бейдж окружения виджета + мини-карта ключевых путей
Бейдж окружения виджета + мини-карта ключевых путей
1
Задача
ChatGPT Apps, 2 уровень, 1 лекция
Недоступна
Серверный маршрут /api/time как “маяк” для понимания app/api
Серверный маршрут /api/time как “маяк” для понимания app/api
Комментарии (2)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Mikhail_K Уровень 4
5 февраля 2026
Ничего не понятно. Не стоит видимо дальше курс продолжать. Курс ради курса...
Зепп Бранниган Уровень 80 Moderator
10 апреля 2026
Понимаем, что материал может показаться сложным, так как он предполагает наличие определенных знаний. Рекомендуем сначала пройти наш курс Web FullStack, чтобы получить необходимую основу, а затем вернуться к этому курсу.