1. Wprowadzenie
Projekt ChatGPT App HelloWorld — to nie „magiczna czarna skrzynka od CodeGym, w której lepiej nic nie ruszać”. To zwykły projekt Next.js, w którym jednocześnie żyją:
- frontend renderowany wewnątrz ChatGPT,
- serwer MCP, który odpowiada na wywołania narzędzi (tools),
- ustawienia, które sklejają to wszystko z ChatGPT.
Jeśli nie wiesz, gdzie co leży, zwykle pojawiają się trzy klasyczne scenariusze:
- Programista przypadkowo używa window w pliku serwerowym, łapie crash i zaczyna nienawidzić cały stos.
- Próbuje dodać przycisk w UI, ale edytuje nie ten page.tsx (np. korzeń aplikacji zamiast widżetu) i nie widzi zmian w ChatGPT.
- Przypadkowo umieszcza OPENAI_API_KEY po stronie klienta i klucz wycieka do przeglądarki.
Dlatego dzisiejszy cel — zaznaczyć mapę: gdzie jest UI, gdzie MCP, gdzie konfiguracje i dokąd iść, gdy chcesz:
- zmienić wygląd widżetu;
- dodać nowe narzędzie (tool);
- podkręcić jakąś ustawę platformową (CORS, assetPrefix itp.).
2. Wysokopoziomowa anatomia projektu
Projekt Next.js ChatGPT App HelloWorld używa App Router i jest zorganizowany wokół folderu app/. W nim, w jednym drzewie stron, współistnieją:
- UI widżetu, który będzie renderowany wewnątrz ChatGPT,
- endpoint MCP, który będzie obsługiwał wywołania narzędzi.
Typowe drzewo (uproszczone; nazwy folderów w twoim szablonie mogą się różnić, ale wzorzec jest ten sam):
my-chatgpt-app/
├─ app/
│ ├─ api/ // REST API
│ │ └─ time/ // GET /api/time zwraca czas na serwerze
│ │ └─ route.ts
│ ├─ hooks/ // Zestaw hooków z oficjalnego Apps SDK
│ │ ├─ use-call-tool.ts
│ │ ├─ use-display-mode.ts
│ │ └─ use-open-external.ts
│ ├─ mcp/ // Serwer MCP: tu ChatGPT puka, gdy wywołuje tools
│ │ └─ route.ts
│ ├─ globals.css // Główny globals.css całej aplikacji
│ ├─ layout.tsx // Główny layout całej aplikacji
│ └─ page.tsx // Strona widżetu wewnątrz ChatGPT
├─ public/ // Zasoby statyczne: ikony, manifest itp.
├─ next.config.ts // Konfig Next.js i ustawienia specyficzne dla Apps (assetPrefix itp.)
├─ proxy.ts // CORS/nagłówki do pracy wewnątrz iframe (dawny middleware.ts)
├─ package.json // Zależności projektu
├─ tsconfig.json // Konfiguracja TypeScript
└─ .env.local // Sekrety: OPENAI_API_KEY itd.
Jeśli widżetów jest kilka, zwykle kładzie się je nie w app/page.tsx, tylko w app/widget/page.tsx. Ale logika się nie zmienia: wciąż jest jedna strona‑widżet i jeden endpoint pełniący rolę serwera MCP.
Wygodnie myśleć tak: twój repozytorium — to „dwuobliczny Janus”:
- jedno „oblicze” — ścieżka /mcp, dokąd idzie ChatGPT, gdy chce wywołać narzędzie;
- drugie „oblicze” — ścieżka /widget (albo /), która ładuje się w iframe, gdy model decyduje się pokazać twój UI.
Aby się nie gubić, utrwalmy w głowie trzy grupy plików:
- Warstwa UI — wszystko, co związane ze stronami React/Next (app/widget, komponenty, style).
- Warstwa MCP — app/mcp/route.ts i pliki, z których korzysta.
- Warstwa „klejąca” i konfiguracje — next.config.ts, proxy.ts, .env.local, package.json, tsconfig.json.
Nieco niżej omówimy każdą z tych warstw.
3. Gdzie żyje widżet: folder app/widget i/lub app/page.tsx
Zacznijmy od tego, czego będziesz dotykać najczęściej — widżetu, czyli UI widocznego wewnątrz ChatGPT.
W większości aktualnych projektów istnieje albo:
- folder app/widget/page.tsx — widżet żyje pod osobnym prefiksem /widget,
- albo korzeń app/page.tsx — widżet pokrywa się ze stroną główną.
Główne cechy pliku z widżetem:
- na samej górze jest 'use client', ponieważ komponent działa w przeglądarce, komunikuje się z window i Apps SDK;
- to zwykły komponent React, który renderuje znacznik i (nieco później w kursie) rozmawia z window.openai.
Najprostszy przykład edukacyjnego widżetu (bardzo podobny możesz już zobaczyć u siebie w projekcie):
// 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">
Tutaj będziemy budować interfejs (UI) naszego widżetu.
</p>
</main>
);
}
Jeśli w twoim szablonie widżet leży bezpośrednio w app/page.tsx, kod będzie niemal taki sam, tylko bez pośredniego folderu widget.
Zwróć uwagę na kilka kwestii.
Po pierwsze, dyrektywa 'use client' jest obowiązkowa: widżet czyta/pisze do window.openai, słucha zdarzeń itp., a to możliwe tylko w komponencie klienckim. Jeśli ją usuniesz, Next spróbuje zrobić stronę serwerową i dostaniesz błędy w stylu „window is not defined”.
Po drugie, to zwykły, wcale nie magiczny komponent React. Możesz:
- dzielić go na podkomponenty w components/,
- używać Tailwind albo dowolnego innego systemu CSS,
- podłączać konteksty, hooki itd.
Po trzecie, później właśnie tutaj będziesz:
- czytać window.openai.toolInput oraz window.openai.toolOutput, aby renderować prawdziwe dane,
- zapisywać widgetState poprzez window.openai.setWidgetState,
- wywoływać openExternal, callTool i inne metody runtime’u.
Na razie wystarczy wiedzieć: jeśli chcesz zmienić interfejs wizualny — niemal na pewno trafisz do app/widget/page.tsx lub app/page.tsx.
4. Główny layout: app/layout.tsx jako „ramka” całej aplikacji
Kolejny ważny plik to app/layout.tsx. On:
- definiuje strukturę HTML (<html>, <body>),
- podłącza globalne style (globals.css),
- często inicjuje „bootstrap” dla Apps SDK (otoczkę, która słucha window.openai i przekazuje dane do Reacta).
Uproszczony przykład:
// 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>
);
}
Nazwa NextChatSDKBootstrap jest tu umowna, w twoim szablonie może to być OpenAIAppProvider albo inny komponent. Jego zadanie jest zwykle jedno: skonfigurować połączenie między drzewem React a runtime’em Apps SDK, zasubskrybować globalne dane (theme, displayMode, toolInput itd.) i rozdać je dzieciom.
Ważny wniosek praktyczny: jeśli potrzebujesz podłączyć globalny kontekst, style lub bibliotekę UI (np. shadcn/ui) — miejsce na to to niemal zawsze app/layout.tsx (albo layout wewnątrz app/widget dla ustawień i komponentów specyficznych właśnie dla widżetu).
Omówienie NextChatSDKBootstrap
NextChatSDKBootstrap podpatrzyłem w oficjalnym szablonie od Vercel. Jeśli nie wiedziałeś, to właśnie ci goście stworzyli i rozwijają Next. Na ich stronie jest dobry wpis o ChatGPT App na Next. Jest też Starter Template. Choć w kilku miejscach jest już trochę przestarzały, są duże szanse, że będą dbać o jego aktualność.
Wyróżnijmy 5 kluczowych rzeczy, które daje NextChatSDKBootstrap:
- 1. Naprawia problemy z hydratacją
Chodzi o to, że ChatGPT najpierw ładuje HTML twojego widżetu na swój serwer, czyści go i łatkuje. W efekcie mechanizm hydratacji narzeka i sypie ostrzeżeniami w konsoli. To może utrudnić przejście review. - 2. Łata historię przeglądarki
Twój widżet ładowany jest w iframe ze specjalnej domeny w ChatGPT. Jeśli będziesz używać własnej domeny, zepsujesz piaskownicę. Dlatego w historii przeglądarki zapisywana jest tylko ścieżka bez domeny. - 3. Nadpisuje funkcję fetch()
Każdy twój fetch() na adresy względne bez domeny nie zadziała w widżecie, bo domena w iframe jest inna. Podmieniamy więc funkcję fetch() na własną, która wysyła żądania bez domeny pod właściwy URL. Jeśli domena jest podana, wszystko działa bez zmian. - 4. Działa klikanie linków
Jeśli linki otwierałyby się wewnątrz iframe, ChatGPT tego nie zaakceptuje. Dlatego dodano kod, który nasłuchuje kliknięć w linki i otwiera je w zewnętrznym oknie przez openExternal(). - 5. Ustawienie head base (DEPRECATED)
Ten kod dodawał też <base> w <head>, ale to już nie działa. Piaskownica resetuje każdy ustawiony base, więc polecam używać absolutnych linków do wszystkiego: skrypty, zasoby, fonty, API itp.
5. Serwer MCP: app/mcp/route.ts
Przechodzimy teraz do drugiej połowy „dwuoblicznego Janusa” — serwera, który rozmawia z ChatGPT przez MCP.
Plik app/mcp/route.ts to zwykły Route Handler App Routera, który:
- przyjmuje żądania HTTP od ChatGPT (zwykle POST z payloadem JSON w formacie MCP),
- przekazuje je do serwera MCP (na bazie @modelcontextprotocol/sdk albo cienkiej otoczki),
- zwraca z powrotem odpowiedź JSON w formacie MCP.
Są dwie opcje: można pisać na gołym MCP SDK, albo spróbować wygładzić kanty i użyć kilku klas od Next/Vercel.
Oto wariant na czystym TS MCP SDK:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// 1. Tworzymy serwer MCP
const server = new McpServer({
name: "simple-mcp-server",
version: "1.0.0",
});
// 2. Rejestrujemy MCP Resources
// 3. Rejestrujemy MCP Tools
// 4. Transport HTTP
const transport = new HttpServerTransport({
port: 3001,
path: "/mcp",
});
// 5. Start serwera
await server.connect(transport);
Ale lepiej użyć kilku gotowych klas, aby pracowało się przyjemniej:
// 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;
Tutaj McpGateway to klasa‑otoczka wokół McpServer, którą gdzieś tworzysz (np. w lib/mcp/server.ts) przy pomocy SDK. W naszym przypadku całość mieści się w app/mcp/route.ts. Przeanalizujmy dokładnie, co znajduje się w tym pliku.
type ContentWidget
Na początku pliku opisany jest typ ContentWidget. Zawiera wszystkie dane widżetu i używany jest w dwóch miejscach: przy rejestracji widżetu jako mcp‑resource oraz gdy mcp‑tool zwraca metadata, gdzie wskazuje, którego widżetu użyć do wyświetlenia danych, które zwrócił.
type ContentWidget = {
id: string; // Unikalna nazwa/klucz
title: string; // Title
description: string; // Description
templateUri: string; // Unikalny URI widżetu, może być dowolny. Na nic nie wpływa.
invoking: string; // Napis nad widżetem podczas ładowania
invoked: string; // Napis nad widżetem po załadowaniu
html: string; // Cały kod HTML widżetu.
widgetDomain: string; // „Domena” widżetu. Na nic nie wpływa.
};
class McpGateway
Klasa‑otoczka nad McpServer, upraszcza pewne rzeczy. Zawiera 6 metod:
- initialize() — tutaj ładujemy HTML naszego widżetu
- registerResources() — rejestrujemy widżety jako mcp‑resources
- registerTools() — rejestrujemy funkcje jako mcp‑tools
- widgetMeta() — zwraca metadane widżetu
- getAppsSdkCompatibleHtml() — ładuje kod HTML widżetu i lekko go łata
- makeImgUrlsAbsolute() — łata HTML: zamienia linki do obrazków na absolutne
Przejdźmy przez nie dokładniej:
public async initialize()
Ta metoda ładuje z internetu kod HTML widżetów i wypełnia obiekt typu ContentWidget.
{
id: "hello_world", // Unikalny klucz widżetu
templateUri: "ui://widget/hello_world.html", // Unikalny URI widżetu. „ui:” nic nie znaczy.
title: "HelloWorld Widget", // Nazwa widżetu
description: "Displays the HelloWorld widget", // Wyjaśnienie dla LLM, co robi widżet
invoking: "Loading widget...", // Napis nad widżetem podczas jego ładowania
invoked: "Widget loaded", // Napis nad widżetem po jego załadowaniu
html: htmlWidget, // HTML widżetu
widgetDomain: baseURL, // „Domena” widżetu. Obecnie na nic nie wpływa.
}
public registerResources()
Rejestruje widżety jako mcp‑resources. Wywołuje metodę server.registerResource(), do której przekazywane są 4 parametry:
- id/klucz zasobu MCP
- URI zasobu (potrzebne konkretnie dla protokołu MCP; dla widżetu to w praktyce synonim unikalnego adresu)
- Metadane zasobu MCP
- Funkcja zwracająca zasób MCP
Metadane widżetu
{
title: widget.title, // Nazwa zasobu/widżetu
description: widget.description, // Opis zasobu/widżetu
mimeType: "text/html+skybridge", // Ważne! Tylko taki HTML będzie renderowany jako widżet
_meta: {
"openai/widgetDescription": widget.description, // Opis widżetu
"openai/widgetPrefersBorder": true, // Prosimy ChatGPT o obramowanie widżetu
},
}
Widżet jako zasób MCP
{
uri: uri.href, // Nasz URI (brany z parametru uri)
mimeType: "text/html+skybridge", // Ważne! Tylko taki HTML będzie renderowany jako widżet
text: widget.html, // HTML widżetu
_meta: {
"openai/widgetDescription": widget.description, // Opis widżetu
"openai/widgetPrefersBorder": true, // Prosimy ChatGPT o obramowanie widżetu
"openai/widgetDomain": widget.widgetDomain, // „Domena” widżetu. Obecnie na nic nie wpływa.
"openai/widgetCSP": { // Ważne! Domena dostępna widżetowi:
connect_domains: [ // Domeny do połączeń (fetch itp.)
baseURL,
"https://codegym.cc",
],
resource_domains: [ // Domeny do zasobów (css/fonts/img)
baseURL,
"https://codegym.cc",
"https://cdn.tailwindcss.com",
"https://persistent.oaistatic.com",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com"
]
}
},
}
W przyszłości jeszcze nie raz wrócimy do openai/widgetCSP, ale teraz chciałbym podkreślić 2 kwestie:
- connect_domains — lista domen dla:
- fetch()
- ładowania skryptów
- openExternal()
- resource_domains — lista domen dla:
- obrazków
- CSS
- fontów
Teoretycznie możesz wpisać 200 domen, ale czy z taką listą przejdziesz review — to już inna sprawa.
Przejrzałem też te parametry w już opublikowanych aplikacjach i znalazłem tam amplitude.com. To też dobra wiadomość. Myślę, że porządna analityka nikomu nie zaszkodzi.
public registerTools()
Rejestruje funkcje jako mcp‑tools. Wywołuje metodę server.registerTool(), do której przekazywane są 3 parametry:
- id/klucz narzędzia MCP
- Metadane narzędzia MCP
- Funkcja, która zwraca narzędzie MCP
Metadane narzędzia
Wszystkie parametry na tej liście są ważne. Szczegóły omówię w kolejnych wykładach.
{
title: widget.title, // Nazwa narzędzia
description: "Returns HelloWorld widget", // Ważne! Opis tego, co robi narzędzie
inputSchema: z.object({}).describe("No inputs"), // Schemat parametrów narzędzia. Może być Zod
_meta: this.widgetMeta(widget), // Metadane widżetu: który widżet wyświetlić
annotations: {
destructiveHint: false, // Metoda robi coś istotnego — potrzebny confirm
openWorldHint: false, // Metoda zmienia coś w usługach zewnętrznych
readOnlyHint: true // Metoda nic nie zmienia
},
}
Funkcja, która robi coś ważnego
async (input, extra) => {
// 1. Walidacja parametrów
// 2. Robimy coś ważnego
return {
content: [{ type: "text", text: "HelloWorld MCP-tool" }], // Opis wyniku dla AI
structuredContent: { // Ważne! To jest JSON wyniku.
timestamp: new Date().toISOString() // Może zawierać dowolne dane.
},
_meta: this.widgetMeta(widget), // Metadane widżetu, który wyświetla JSON
}; // Może nie występować — wtedy widżetu nie będzie
}
private widgetMeta(widget: ContentWidget)
Zwraca metadane widżetu — na ich podstawie ChatGPT określi, którego widżetu użyć do wyświetlenia wyniku JSON.
{
"openai/outputTemplate": widget.templateUri, // URI widżetu
"openai/toolInvocation/invoking": widget.invoking, // Napis nad widżetem podczas ładowania
"openai/toolInvocation/invoked": widget.invoked, // Napis nad widżetem po załadowaniu
"openai/widgetAccessible": true, // MCP‑tool można wywoływać z widżetu
"openai/resultCanProduceWidget": true, // MCP‑tool zwróci widżet
}
Osobno chciałbym omówić tak prostą rzecz jak "openai/outputTemplate". W protokole MCP są 3 byty (o których więcej dowiesz się w module 6):
- MCP Resources
- MCP Templates
- MCP Tools
Otóż ten "openai/outputTemplate" nie ma żadnego związku z MCP Templates. MCP Templates w ogóle nie są używane w ChatGPT Apps. Słowo template jest tu z tego powodu:
Widżety wymyślono jako szablon do wyświetlania JSON. MCP‑tool zwraca jakiś JSON, AI wyświetla widżet, przekazuje mu JSON przez parametr ToolOutput, a widżet ładnie prezentuje ten JSON. outputTemplate — to po prostu synonim widżetu.
Myślę, że na tym zakończymy. Szczegóły omówimy w module 4: jak dokładnie opisywać narzędzia, JSON Schema i handler’y. Teraz wystarczy rozumieć: jeśli coś jest związane z narzędziami (tools) i logiką — szukaj w pobliżu app/mcp/route.ts.
6. Konfiguracja i „klej”: next.config.ts, middleware.ts, .env i spółka
Teraz rozbierzemy główny zestaw plików potrzebnych do tego, by twój projekt Next.js działał poprawnie wewnątrz iframe ChatGPT i był dostępny dla ChatGPT przez tunel HTTPS (ngrok, Cloudflare Tunnel itp.; o tunelach porozmawiamy jeszcze osobno).
next.config.ts
W tym pliku, poza standardowymi ustawieniami Next.js, często konfiguruje się:
- assetPrefix — aby statyka (JS, CSS z /_next/) poprawnie ładowała się nie z domeny ChatGPT, tylko z twojego adresu deweloperskiego (tunelu lub Vercel);
- inne ustawienia specyficzne dla szablonu (np. flagi eksperymentalne pod Next 16).
W praktyce wygląda to jak zwykły eksport nextConfig z potrzebnymi polami. Dla wykładu ważne jest jedno: jeśli w ChatGPT widżet nie może załadować CSS/JS, bardzo często winowajcą jest assetPrefix.
proxy.ts (dawny middleware.ts)
Ten plik wstawia warstwę middleware między żądaniem z ChatGPT a twoimi trasami. W szablonie zwykle:
- ustawia nagłówki CORS, aby iframe ChatGPT w ogóle miał prawo odwoływać się do twojego serwera;
- czasem konfiguruje dodatkowe nagłówki dla React Server Components.
Nie musisz teraz znać wszystkich niuansów. Warto tylko pamiętać: jeśli ChatGPT skarży się na CORS albo widzisz dziwne błędy w DevTools o zakazie dostępu, zajrzyj do proxy.ts.
.env
Plik .env (lub .env.local) — miejsce na sekrety i parametry środowiskowe:
- OPENAI_API_KEY (jeśli serwer MCP sam woła OpenAI API),
- adresy twoich wewnętrznych API,
- tokeny usług zewnętrznych itd.
Jest ważny niuans: w Next.js zmienne zaczynające się od NEXT_PUBLIC_ automatycznie trafiają do bundla JS i stają się dostępne w przeglądarce. Nigdy nie rób tak z OPENAI_API_KEY; sekrety muszą być wyłącznie zmiennymi serwerowymi.
package.json i tsconfig.json
W package.json zobaczysz:
- wersje Next.js, React, Apps SDK, MCP SDK i innych zależności;
- skrypty dev, build, start, a czasem także pomocnicze komendy (linter, formatter itd.).
W tsconfig.json leżą dobrze znane ustawienia TypeScript:
- ścieżki aliasów (@/lib, @/components),
- tryb ścisły,
- targety kompilacji.
Z punktu widzenia tego kursu najważniejsze jest rozumieć, że szablon używa zwykłego stosu TypeScript i możesz go rozszerzać w standardowy sposób.
7. Szybki „nawigator po projekcie” dla developera
Ustalmy, dokąd iść, gdy chcesz zrobić typowe rzeczy. Bez list, po prostu w formie mini‑scenariuszy.
Jeśli chcesz zmienić tekst/przyciski w widżecie, otwierasz plik z UI widżetu: to albo app/widget/page.tsx, albo app/page.tsx — w zależności od szablonu. Tam edytujesz JSX, dodajesz nowe komponenty, podłączasz system designu. I właśnie tutaj będziesz używać runtime’u Apps SDK (window.openai lub wygodnych hooków) do wyświetlania danych.
Jeśli trzeba dodać nowy przycisk, który coś robi po stronie serwera, i tak zaczynasz od pliku UI. Przycisk w widżecie po kliknięciu wywoła window.openai.callTool, a implementację tego narzędzia dodasz w konfiguracji serwera MCP, czyli w kodzie obok app/mcp/route.ts. Powiązanie UI ↔ logika tooli będziemy rozbierać w modułach 4 i dalszych.
Gdy chcesz nauczyć ChatGPT nowej funkcjonalności (np. „wyszukiwania wycieczek” albo „doboru produktów”), idziesz do warstwy MCP (pliki importowane z app/mcp/route.ts). Tam rejestrujesz nowe narzędzie z JSON Schema, opisem i handlerem. Widżet może potem czytać wynik przez window.openai.toolOutput i ładnie go wyświetlać.
Jeśli posypała się statyka albo widżet dziwnie wygląda tylko w ChatGPT, a lokalnie wszystko gra, przypomnij sobie o warstwie „klejącej”. W pierwszej kolejności sprawdź next.config.ts (szczególnie assetPrefix) oraz middleware.ts/proxy.ts (CORS). Jeśli niedawno zmieniałeś tunel, URL albo wdrażałeś na Vercel, to poprawność tych ustawień jest kluczowa.
Wreszcie, jeśli podejrzewasz problemy z kluczami albo środowiskiem, twoja trójka plików to — .env.local, package.json (aby upewnić się, jakie zależności i skrypty faktycznie są używane) i logi serwera developerskiego. To właśnie ten zestaw odpowiada za to, by MCP miał dostęp do potrzebnych sekretów i usług.
8. Mini‑praktyka: poznajemy system plików „ręcznie”
Teoria teorią, ale utrwalmy ręcznie, gdzie co leży. Kroki poniżej możesz wykonać od razu w edytorze/IDE.
Spróbuj otworzyć w swoim projekcie folder app i znaleźć, który plik odpowiada za widżet. Jeśli szablon używa app/page.tsx, właśnie tam zobaczysz znajomy napis w rodzaju „HelloWorld — ChatGPT App” lub tekst powitalny. Jeśli nie ma osobnego folderu widżetu, otwórz app/page.tsx i upewnij się, że jest tam 'use client' i jakaś struktura JSX.
Następnie znajdź app/mcp/route.ts. Zwróć uwagę, jakie moduły importuje: zwykle zobaczysz albo bezpośrednie użycie MCP SDK, albo wywołanie funkcji pomocniczej z lib/mcp/*. Oceń, na ile „cienka” jest ta warstwa — w idealnym przypadku prawie nie ma tam logiki biznesowej, tylko „przyjął JSON → przekazał serwerowi → zwrócił JSON”.
Potem zajrzyj do next.config.ts oraz proxy.ts/middleware.ts. Nie musisz rozumieć wszystkiego, co tam jest — po prostu zanotuj, że:
- next.config.ts odpowiada za konfigurację Next, w tym zasady budowania i serwowania assetów;
- proxy.ts ingeruje w żądania HTTP (niemal na pewno zobaczysz tam pracę na nagłówkach).
Na koniec otwórz .env lub .env.local i upewnij się, że twoje klucze leżą właśnie tam, a nie w kodzie. Jeśli gdzieś zobaczysz NEXT_PUBLIC_OPENAI_API_KEY — to świetny moment, by to poprawić, póki mowa tylko o lokalnym developmencie.
9. Schemat wizualny: jak ChatGPT wchodzi w interakcję z twoim szablonem
Aby obraz był kompletny, warto spojrzeć na prosty przepływ:
flowchart TD
U[Użytkownik w ChatGPT] -->|Wpisuje zapytanie| M[Model ChatGPT]
M -->|Wywołuje tool| MCP["Twój endpoint MCP
app/mcp/route.ts"]
MCP -->|"Odpowiedź JSON MCP (structuredContent, _meta, link do UI)"| M
M -->|Decyduje, aby pokazać UI| WIDGET_URL["URL widżetu
(/widget lub /)"]
WIDGET_URL -->|iframe| W[Twój widżet
app/page.tsx]
W -->|czyta window.openai.toolOutput
+ widgetState| U
Warto zauważyć, że inicjatorem jest niemal zawsze model ChatGPT, a nie przeglądarka użytkownika, jak w klasycznej aplikacji webowej. Twój app/mcp/route.ts i app/widget/page.tsx — to po prostu dwoje różnych „drzwi” do tego samego projektu Next.js: jedne dla robota (MCP), drugie dla UI.
Jeśli będziesz mieć w głowie tę mapę projektu (widżet → warstwa MCP → konfiguracje) i świadomie unikać wymienionych min, dalej w kursie skupisz się już na logice i UX swojego App, a nie na szukaniu „tego jednego pliku, który wszystko psuje”.
10. Typowe błędy przy pracy ze strukturą szablonu
Błąd nr 1: Mylenie widżetu ze zwykłą stroną serwisu.
Czasem developer widzi w szablonie i app/page.tsx, i app/widget/page.tsx, edytuje „nie ten” plik i dziwi się, czemu zmiany nie pojawiają się w ChatGPT. Widżet — to ta strona, która używana jest jako outputTemplate/iframe dla narzędzia MCP. Jeśli zmieniasz inny rout, ChatGPT nawet się o tym nie dowie. Zawsze sprawdzaj README szablonu i patrz, jaki URL wskazano jako widżet.
Błąd nr 2: Pisanie kodu klienckiego (window, document) w serwerowych plikach MCP.
Plik app/mcp/route.ts i wszystko, co importuje, wykonuje się na serwerze. Każda próba użycia tam window lub DOM‑API skończy się crashem runtime’u. Jeśli chcesz zrobić coś w UI, to prawie na pewno powinno być w plikach pod app/widget albo innych komponentach klienckich. Warstwa MCP — to czysty backend: żądania, bazy, zewnętrzne API i formowanie ustrukturyzowanej odpowiedzi.
Błąd nr 3: Ignorowanie assetPrefix i ustawień CORS.
Lokalnie na localhost:3000 wszystko działa świetnie, ale gdy otworzysz App przez tunel w ChatGPT — style znikają, JS się nie ładuje, a w konsoli jest mnóstwo błędów CORS. Często powodem jest to, że konfiguracja next.config.ts lub middleware.ts/proxy.ts nie uwzględnia nowego publicznego URL albo została przypadkowo popsuta przy refaktorze. Zmieniając te pliki, pamiętaj, że twój kod będzie żył wewnątrz iframe na domenie ChatGPT, a nie bezpośrednio na localhost.
Błąd nr 4: Trzymanie sekretów nie w .env, tylko w kodzie albo w zmiennych NEXT_PUBLIC_*.
Ukrywanie OPENAI_API_KEY w const apiKey = 'sk-...' gdzieś w app/widget/page.tsx — to najgorszy pomysł: klucz trafi do bundla JS i wycieknie do każdego użytkownika. Prawie tak samo źle — zrobić zmienną NEXT_PUBLIC_OPENAI_API_KEY, bo prefiks NEXT_PUBLIC_ gwarantuje trafienie do przeglądarki. Zawsze kładź sekrety do .env bez tego prefiksu i używaj ich tylko po stronie serwera (serwer MCP, funkcje backendowe).
Błąd nr 5: Uznawanie szablonu za „zbyt mądry” i strach przed jego dotknięciem.
Czasem developerzy traktują oficjalny starter jak coś świętego: „lepiej tam nie zaglądać, bo może zepsuję integrację”. W rezultacie piszą cały swój kod gdzieś obok, komplikują architekturę i i tak wpadają na te same miny. W rzeczywistości szablon — to po prostu starannie złożony projekt Next.js z kilkoma ustawieniami pod Apps SDK. Zrozumienie, że app/ — to UI i MCP, a reszta — zwykłe konfiguracje, bardzo uwalnia: zaczynasz pracować z kodem jak z typowym projektem React/Next, a nie magiczną skrzynką.
Błąd nr 6: Próba rozwiązania wszystkich problemów „na poziomie widżetu”.
Czasem kusi, by wszystko robić w UI: i logikę biznesową, i dostęp do baz, i zewnętrzne zapytania do API. W kontekście ChatGPT Apps to szczególnie zły pomysł: widżet żyje w bardzo twardej piaskownicy, nie widzi twoich sekretów i mocno zależy od window.openai. Jeśli trzeba zrobić coś poważnego — miejsce jest w warstwie MCP i usługach backendowych, a widżet powinien być cienką warstwą prezentacji, która wyświetla ustrukturyzowane dane i, w razie potrzeby, wyzwala narzędzia.
GO TO FULL VERSION