1. 導言
ChatGPT App HelloWorld 專案並不是「CodeGym 的魔法黑盒,最好什麼都別動」。它是一個普通的 Next.js 專案,只是裡面同時包含了:
- 在 ChatGPT 內部渲染的前端,
- 回應工具(tools)呼叫的 MCP 伺服器,
- 將上述一切與 ChatGPT 串接起來的設定。
如果搞不清楚各個部分的位置,往往會出現三種典型情況:
- 開發者不小心在伺服器端檔案裡寫了 window,導致崩潰,從此痛恨整個技術棧。
- 想在 UI 裡加一個按鈕,卻改錯了 page.tsx(例如改了應用根頁而不是小工具),因此在 ChatGPT 裡看不到變更。
- 不小心把 OPENAI_API_KEY 放進了客戶端程式碼,結果金鑰被洩漏到瀏覽器。
所以今天的目標是——畫出這張地圖:UI 在哪、MCP 在哪、設定檔在哪,當你想要:
- 修改小工具的外觀;
- 新增一個 tool;
- 微調某些平台設定(CORS、assetPrefix 等)。
2. 專案的高階剖析
ChatGPT App HelloWorld 的 Next.js 專案使用 App Router,並以 app/ 資料夾為核心。在同一個頁面樹裡並存:
- 會在 ChatGPT 裡渲染的小工具 UI,
- 用來處理 tool 呼叫的 MCP 端點(endpoint)。
典型的目錄樹(簡化版,你的範本可能資料夾命名略有不同,但模式一致):
my-chatgpt-app/
├─ app/
│ ├─ api/ // REST API
│ │ └─ time/ // GET /api/time 回傳伺服器時間
│ │ └─ route.ts
│ ├─ hooks/ // 官方 Apps SDK 的 Hook 集合
│ │ ├─ 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/ // 靜態資源:圖示、manifest 等
├─ next.config.ts // Next.js 設定與 Apps 特定設定(assetPrefix 等)
├─ proxy.ts // 在 iframe 內運作需要的 CORS/標頭(原 middleware.ts)
├─ package.json // 專案相依套件
├─ tsconfig.json // TypeScript 設定
└─ .env.local // 機密:OPENAI_API_KEY 等
如果有多個小工具,通常不會放在 app/page.tsx,而是放在 app/widget/page.tsx。但邏輯不變:一樣會有一個小工具頁面, 以及一個扮演 MCP 伺服器角色的 endpoint。
你可以這樣理解:你的版本庫就像「雙面雅努斯」:
- 一張「臉」是 /mcp 路徑,ChatGPT 想呼叫工具時會打到這裡;
- 另一張「臉」是 /widget(或 /)路徑, 當模型決定顯示你的 UI 時,會把它載入到 iframe。
為了不混淆,記住三組檔案:
- 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
先從你最常動到的部分開始——小工具,也就是會在 ChatGPT 內顯示的 UI。
在大多數的當代專案中,要嘛是:
- 資料夾 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 系統,
- 掛載 context、hooks 等等。
第三,之後你會在這裡:
- 讀取 window.openai.toolInput 與 window.openai.toolOutput, 以渲染真實資料,
- 透過 window.openai.setWidgetState 儲存 widgetState,
- 呼叫 openExternal、callTool 與其他執行期方法。
目前只要知道:如果你想改視覺介面——幾乎可以肯定要去 app/widget/page.tsx 或 app/page.tsx。
4. 根級 layout:app/layout.tsx 作為整個應用的「框架」
下一個重要檔案是 app/layout.tsx。它會:
- 定義 HTML 結構(<html>、<body>),
- 掛上全域樣式(globals.css),
- 經常初始化 Apps SDK 的「bootstrap」(包一層監聽 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 等),並把它們提供給子元件。
實務上的重點:若你需要掛上全域的 context、樣式或 UI 函式庫 (例如 shadcn/ui),幾乎都應該放在 app/layout.tsx (或放在 app/widget 下的 layout,若它只針對小工具)。
解析 NextChatSDKBootstrap
我是在 Vercel 的官方範本裡看到 NextChatSDKBootstrap 的。 如果你不知道,Vercel 就是創建並持續維護 Next 的團隊。他們網站上有一篇很好的文章: 在 ChatGPT 內運行 Next 的深度解析。 也有提供 Starter Template。 雖然有些地方稍嫌過時,但我認為他們很可能會持續更新維護。
來看一下 NextChatSDKBootstrap 帶來的五個關鍵好處:
- 1. 修正水合(hydration)問題
問題在於 ChatGPT 會先把你的小工具 HTML 載到自己的伺服器,進行清理與修補。 結果導致水合機制抱怨並在主控台丟出許多 Warning,可能讓你過不了 review。 - 2. 修補瀏覽器歷史紀錄
你的小工具是以 iframe 從 ChatGPT 的特殊網域載入。 如果你使用自己的網域,會破壞沙盒環境。因此歷史紀錄只會儲存不含網域的路徑。 - 3. 改寫 fetch() 函式
你在小工具內對相對位址、不含網域的 fetch() 將無法運作, 因為 iframe 的網域不同。所以我們會替換 fetch() 成自有版本,讓不含網域的請求能被送往正確的 URL。如果原本就有網域,一切照常。 - 4. 點擊連結可正確開啟
若連結在 iframe 內開啟,ChatGPT 會不允許。 因此加上了程式碼來攔截連結點擊,並透過 openExternal() 在外部視窗開啟。 - 5. 設定 head base(已棄用)
這段程式以前會在 <head> 中加入 <base>, 但現在已經無效。沙盒會移除任何設定的 base, 因此建議對所有資源(腳本、資源、字型、API 等)都使用絕對連結。
5. MCP 伺服器:app/mcp/route.ts
現在來看「雙面雅努斯」的另一面——與 ChatGPT 透過 MCP 對話的伺服器。
檔案 app/mcp/route.ts 是 App Router 的一般 Route Handler,會:
- 接收來自 ChatGPT 的 HTTP 請求(通常是帶 MCP 格式 JSON 負載的 POST),
- 把它們轉交給 MCP 伺服器(基於 @modelcontextprotocol/sdk 或薄封裝),
- 再返回 MCP 格式的 JSON 回應。
有兩種做法:直接用 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 外的一個類別, 用 SDK 建立(例如在 lib/mcp/server.ts),簡化一些工作。 在我們的情境中它全部都放在 app/mcp/route.ts。我們來完整解析這個檔案裡的內容。
type ContentWidget
在檔案開頭定義了 ContentWidget 型別。它包含小工具的所有資料,並用在兩個地方: 註冊小工具為 mcp-resource 時,以及當 mcp-tool 回傳 metadata、指定要用哪個小工具來呈現它回傳的資料時。
type ContentWidget = {
id: string; // 唯一名稱/key
title: string; // 標題
description: string; // 說明
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()——回傳小工具的中繼資料(metadata)
- 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(), 並傳入四個參數:
- MCP 資源的 id/key
- 資源的 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,但此刻先標註兩點:
- connect_domains——用來:
- fetch()
- 載入腳本
- openExternal()
- resource_domains——用來:
- 圖片
- CSS
- 字型
理論上你可以列 200 個網域,但能不能憑這樣的清單通過審核——就不好說了。
我也研究了已上架應用的這些參數,發現裡面有 amplitude.com。 這也是個好消息。我想良好的分析工具對大家都有幫助。
public registerTools()
將函式註冊成 mcp-tools。呼叫 server.registerTool(), 並傳入三個參數:
- MCP tool 的 id/key
- 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, // 工具會做關鍵變更——需要使用者確認
openWorldHint: false, // 工具會改變第三方服務的狀態
readOnlyHint: true // 工具不會做變更(唯讀)
},
}
會做重要事情的函式
async (input, extra) => {
// 1. 參數驗證
// 2. 執行重要邏輯
return {
content: [{ type: "text", text: "HelloWorld MCP-tool" }], // 給 AI 的結果描述
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 協定中有三種實體(你會在模組 6 更深入學到):
- MCP Resources
- MCP Templates
- MCP Tools
需要注意的是,這個 "openai/outputTemplate" 與 MCP Templates 沒有任何關係。 MCP Templates 在 ChatGPT Apps 裡根本不會使用。這裡的 template 一詞來自這個概念:
小工具被設計成用來呈現 JSON 的「樣板」。MCP tool 回傳某個 JSON,AI 顯示小工具並把 JSON 經由 ToolOutput 傳給它, 然後小工具把 JSON 漂亮地呈現出來。outputTemplate 就是「小工具」的同義詞。
我想這部分先到此為止。更多細節我們會在模組 4 解析:如何描述工具、JSON Schema 與處理器。 目前只要理解:凡是與工具(tools)與邏輯相關的東西——就到 app/mcp/route.ts 附近找。
6. 設定與「黏合」:next.config.ts、middleware.ts、.env 與夥伴們
接著解析讓你的 Next.js 專案能在 ChatGPT 的 iframe 內正確運作、並透過 HTTPS 通道(ngrok、Cloudflare Tunnel 等;通道之後會另外說明)被 ChatGPT 存取所需的關鍵檔案集合。
next.config.ts
除了 Next.js 的一般設定外,這個檔案常見會設定:
- assetPrefix——讓靜態資源(/_next/ 底下的 JS、CSS) 不是從 ChatGPT 的網域載入,而是從你的開發 URL(通道或 Vercel)載入;
- 範本需要的其他特定設定(例如 Next 16 的試驗性旗標)。
實際上就是輸出一個包含需要欄位的 nextConfig。 對本課來說,最重要的是:如果在 ChatGPT 中小工具載不到 CSS/JS,罪魁禍首經常是 assetPrefix。
proxy.ts(原 middleware.ts)
這個檔案會在來自 ChatGPT 的請求與你的路由之間插入 middleware。範本裡它通常會:
- 設定 CORS 標頭,讓 ChatGPT 的 iframe 有權向你的伺服器發送請求;
- 有時也會為 React Server Components 設定額外的標頭。
現在不必理解所有細節。只要記得:如果 ChatGPT 抱怨 CORS,或你在 DevTools 看到奇怪的存取禁止錯誤,請查看 proxy.ts。
.env
.env(或 .env.local)是存放機密與環境參數的地方:
- OPENAI_API_KEY(如果 MCP 伺服器本身會呼叫 OpenAI API),
- 你內部 API 的位址,
- 第三方服務的 token 等。
有個重要細節:在 Next.js 中,以 NEXT_PUBLIC_ 開頭的變數會自動被打包進 JS 並在瀏覽器可用。 千萬不要這樣處理 OPENAI_API_KEY;機密只能放在伺服器端變數。
package.json 與 tsconfig.json
在 package.json 你會看到:
- Next.js、React、Apps SDK、MCP SDK 與其他相依套件的版本;
- dev、build、start 指令,以及有時候的輔助指令(linter、formatter 等)。
在 tsconfig.json 則是你熟悉的 TypeScript 設定:
- 別名路徑(@/lib、@/components),
- 嚴格模式,
- 編譯目標。
就本課而言,重點是理解:範本使用一般的 TypeScript 技術棧,你可以用標準方式擴充它。
7. 開發者的快速「專案導覽」
讓我們固定幾個常見情境該去哪裡找,不用清單,就以小劇本形式說明。
想改小工具中的文字/按鈕,打開小工具的 UI 檔案:可能是 app/widget/page.tsx 或 app/page.tsx——取決於範本。 在那裡修改 JSX、加入新元件、串接設計系統。你也會在這裡使用 Apps SDK 執行期 (window.openai 或方便的 hooks)來顯示資料。
若要新增一個按鈕,點擊後在伺服器做點事,同樣從 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 (確認實際使用哪些相依與指令)以及開發伺服器的日誌。 這組合負責確保 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 -->|"MCP 的 JSON 回應(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 層 → 設定),並刻意避開前述那些地雷, 在接下來的課程中你就能把重心放回 App 的邏輯與 UX,而不是在那個「把一切弄壞的檔案」上打轉。
10. 操作範本結構的常見錯誤
錯誤 №1:把小工具當成網站的一般頁面。
有時開發者看到範本裡同時有 app/page.tsx 與 app/widget/page.tsx, 就改了「不該改」的檔案,然後納悶為什麼 ChatGPT 內沒有變化。小工具是被當作 outputTemplate/iframe 來用的那個頁面。如果你改的是其他路由,ChatGPT 根本不會知道。請務必對照範本的 README, 確認哪個 URL 被指定為小工具。
錯誤 №2:在 MCP 伺服器檔案寫入客戶端程式碼(window、document)。
app/mcp/route.ts 與它匯入的一切都在伺服器端執行。任何在那裡使用 window 或 DOM API 的嘗試都會讓執行期崩潰。如果要做 UI 的事情,幾乎可以肯定要放在 app/widget 下或其他客戶端元件中。MCP 層是純後端:處理請求、資料庫、外部 API, 並組裝結構化回應。
錯誤 №3:忽略 assetPrefix 與 CORS 設定。
在本機 localhost:3000 一切看似正常,但一旦透過通道在 ChatGPT 打開 App——樣式不見了、 JS 載不進來、主控台充滿 CORS 錯誤。往往是 next.config.ts 或 middleware.ts/proxy.ts 的設定沒考慮到新的公開 URL,或在重構時不小心破壞了。 修改這些檔案時,務必記得你的程式碼會活在 ChatGPT 網域的 iframe 裡,而不是直接在 localhost。
錯誤 №4:不把機密放在 .env,而是寫在程式或用 NEXT_PUBLIC_* 變數。
把 OPENAI_API_KEY 藏在某個 const apiKey = 'sk-...',例如 app/widget/page.tsx——是最糟糕的主意:金鑰會出現在 JS bundle 中並被任何使用者取得。 幾乎同樣糟的是建立 NEXT_PUBLIC_OPENAI_API_KEY,因為 NEXT_PUBLIC_ 前綴保證會把它放到瀏覽器。務必把機密放在 .env,且不要使用該前綴,並只在伺服器端(MCP 伺服器、後端函式)使用。
錯誤 №5:把範本想得「太聰明」而不敢改動。
有些開發者把官方 starter 當作神聖之物:「最好別碰,免得弄壞整合」。 結果他們把自己的程式碼都寫在邊邊角角,讓架構更複雜,最後還是踩同樣的雷。事實上範本只是整理得漂亮的 Next.js 專案, 外加少量為 Apps SDK 準備的設定。理解 app/ 就是 UI 與 MCP,而其餘是一般設定檔, 會讓你更自在:你會把它當成熟悉的 React/Next 專案來工作,而不是魔法盒子。
錯誤 №6:試圖「在小工具層級」解決所有問題。
有時會想在 UI 端做所有事:商業邏輯、連資料庫、外部 API 請求。在 ChatGPT Apps 的情境下這特別糟糕: 小工具活在非常嚴格的沙盒裡,看不到你的機密,並高度仰賴 window.openai。 如果要處理嚴肅的事——請放在 MCP 層與後端服務;小工具應該是薄薄的展示層,負責呈現結構化資料,必要時觸發工具。
GO TO FULL VERSION