1. Giới thiệu
Dự án ChatGPT App HelloWorld không phải là “hộp đen ma thuật của CodeGym mà tốt nhất đừng đụng vào”. Đây là một dự án Next.js bình thường, chỉ là trong đó đồng thời tồn tại:
- frontend được render bên trong ChatGPT,
- máy chủ MCP trả lời các lời gọi tool (công cụ),
- các thiết lập “kết dính” tất cả với ChatGPT.
Nếu không hiểu cấu trúc nằm ở đâu, thường sẽ xảy ra ba kịch bản kinh điển:
- Lập trình viên vô tình viết window trong tệp phía server, gặp lỗi sập và bắt đầu “ghét” cả stack.
- Cố thêm nút vào UI nhưng lại sửa nhầm page.tsx (ví dụ sửa trang gốc thay vì widget) và không thấy thay đổi trong ChatGPT.
- Vô tình đặt OPENAI_API_KEY ở phía client, và khóa bị lộ ra trình duyệt.
Vì vậy mục tiêu hôm nay là vẽ bản đồ: đâu là UI, đâu là MCP, đâu là config, và nên tìm tới đâu khi bạn muốn:
- thay đổi giao diện widget;
- thêm tool mới;
- chỉnh một số thiết lập nền tảng (CORS, assetPrefix, v.v.).
2. Kiến trúc cấp cao của dự án
Dự án Next.js ChatGPT App HelloWorld sử dụng App Router và được tổ chức xoay quanh thư mục app/. Trong đó, trong cùng một cây trang có:
- UI của widget, sẽ được render bên trong ChatGPT,
- endpoint MCP, sẽ xử lý các lời gọi tool.
Cây thư mục điển hình (đã giản lược; tên thư mục trong template của bạn có thể khác, nhưng mẫu vẫn vậy):
my-chatgpt-app/
├─ app/
│ ├─ api/ // REST API
│ │ └─ time/ // GET /api/time trả về thời gian trên server
│ │ └─ route.ts
│ ├─ hooks/ // Bộ hook từ Apps SDK chính thức
│ │ ├─ use-call-tool.ts
│ │ ├─ use-display-mode.ts
│ │ └─ use-open-external.ts
│ ├─ mcp/ // Máy chủ MCP: ChatGPT gọi vào đây khi gọi tools
│ │ └─ route.ts
│ ├─ globals.css // globals.css gốc của toàn bộ ứng dụng
│ ├─ layout.tsx // layout gốc của toàn bộ ứng dụng
│ └─ page.tsx // Trang widget bên trong ChatGPT
├─ public/ // Static: icon, manifest, v.v.
├─ next.config.ts // Cấu hình Next.js và thiết lập dành riêng cho Apps (assetPrefix, v.v.)
├─ proxy.ts // CORS/headers để chạy trong iframe (trước đây là middleware.ts)
├─ package.json // Phụ thuộc của dự án
├─ tsconfig.json // Cấu hình TypeScript
└─ .env.local // Bí mật: OPENAI_API_KEY, v.v.
Nếu có nhiều widget, thường chúng không nằm ở app/page.tsx mà ở app/widget/page.tsx. Nhưng logic không đổi: vẫn có một trang‑widget và một endpoint đóng vai trò máy chủ MCP.
Hãy hình dung kho mã của bạn như “Janus hai mặt”:
- một “khuôn mặt” — đường dẫn /mcp, nơi ChatGPT tới khi muốn gọi công cụ;
- “khuôn mặt” còn lại — đường dẫn /widget (hoặc /), được tải trong iframe khi mô hình quyết định hiển thị UI của bạn.
Để tránh nhầm lẫn, hãy ghi nhớ ba nhóm tệp:
- Lớp UI — mọi thứ liên quan tới trang React/Next (app/widget, component, style).
- Lớp MCP — app/mcp/route.ts và các tệp mà nó sử dụng.
- Lớp “kết dính” và config — next.config.ts, proxy.ts, .env.local, package.json, tsconfig.json.
Ngay bên dưới chúng ta sẽ đi qua từng lớp này.
3. Widget nằm ở đâu: thư mục app/widget và/hoặc app/page.tsx
Bắt đầu từ thứ bạn sẽ đụng tới nhiều nhất — widget, tức UI sẽ hiển thị bên trong ChatGPT.
Trong phần lớn dự án hiện đại sẽ có hoặc là:
- thư mục app/widget/page.tsx — widget sống dưới prefix riêng /widget,
- hoặc app/page.tsx ở gốc — widget trùng với trang gốc.
Những dấu hiệu chính của tệp chứa widget:
- ở ngay đầu có 'use client', vì component chạy trong trình duyệt, giao tiếp với window và Apps SDK;
- đó là một React component bình thường, render markup và (một chút sau trong khóa học) giao tiếp với window.openai.
Ví dụ đơn giản về một widget học tập (rất có thể bạn đã thấy tương tự trong dự án của mình):
// 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">
Tại đây chúng ta sẽ xây dựng UI cho widget của mình.
</p>
</main>
);
}
Nếu trong template của bạn widget nằm ngay ở app/page.tsx thì mã sẽ gần như y hệt, chỉ là không có thư mục trung gian widget.
Lưu ý vài điểm.
Thứ nhất, directive 'use client' là bắt buộc: widget đọc/ghi vào window.openai, lắng nghe sự kiện, v.v., và điều đó chỉ khả dụng trong client component. Nếu bỏ nó đi, Next sẽ cố biến trang thành server component, và bạn sẽ gặp lỗi kiểu “window is not defined”.
Thứ hai, đây là một React component bình thường, không có “ma thuật”. Bạn có thể:
- tách thành các sub‑component trong components/,
- dùng Tailwind hoặc bất kỳ hệ thống CSS nào khác,
- kết nối context, hook, v.v.
Thứ ba, về sau chính tại đây bạn sẽ:
- đọc window.openai.toolInput và window.openai.toolOutput, để render dữ liệu thực,
- lưu widgetState thông qua window.openai.setWidgetState,
- gọi openExternal, callTool và các phương thức runtime khác.
Hiện tại chỉ cần biết: nếu muốn thay đổi giao diện trực quan — gần như chắc chắn bạn sẽ chỉnh ở app/widget/page.tsx hoặc app/page.tsx.
4. Layout gốc: app/layout.tsx như “khung” cho toàn bộ ứng dụng
Tệp quan trọng tiếp theo là app/layout.tsx. Nó:
- xác định cấu trúc HTML (<html>, <body>),
- kết nối style toàn cục (globals.css),
- thường khởi tạo phần “bootstrap” cho Apps SDK (một lớp bọc lắng nghe window.openai và luân chuyển dữ liệu vào React).
Ví dụ giản lược:
// 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>
);
}
Tên NextChatSDKBootstrap ở đây chỉ là ví dụ; trong template của bạn có thể là OpenAIAppProvider hoặc component khác. Nhiệm vụ thường là một: thiết lập liên kết giữa cây React và runtime của Apps SDK, đăng ký nhận dữ liệu toàn cục (theme, displayMode, toolInput, v.v.) và phân phát cho con.
Kết luận thực tế quan trọng: nếu bạn cần kết nối context toàn cục, style hoặc UI library (ví dụ shadcn/ui) — nơi phù hợp gần như luôn là app/layout.tsx (hoặc layout bên trong app/widget cho các thiết lập và component chỉ dành cho widget).
Phân tích NextChatSDKBootstrap
Tôi “tham khảo” NextChatSDKBootstrap từ template chính thức của Vercel. Nếu bạn chưa biết, họ chính là những người tạo ra và phát triển Next. Trên trang của họ có một bài viết hay về ChatGPT App trên Next. Và còn có Starter Template. Dù ở vài chỗ đã hơi lỗi thời, tôi nghĩ khả năng cao họ sẽ tiếp tục giữ cho nó cập nhật.
Hãy nêu 5 điều then chốt mà NextChatSDKBootstrap mang lại:
- 1. Sửa các vấn đề về hydration
Chuyện là ChatGPT đầu tiên tải HTML của widget của bạn lên server của họ, “dọn dẹp” và patch nó. Kết quả là cơ chế hydration phàn nàn và ném hàng loạt warning vào console. Điều này có thể cản bạn vượt qua vòng review. - 2. Patch lịch sử trình duyệt
Widget của bạn được tải trong iframe từ một domain đặc biệt của ChatGPT. Nếu bạn dùng domain của riêng mình, bạn sẽ phá sandbox. Vì vậy lịch sử trình duyệt chỉ lưu path không kèm domain. - 3. Ghi đè hàm fetch()
Mọi fetch() tới địa chỉ tương đối không kèm domain sẽ không hoạt động trong widget, vì domain của iframe là khác. Do đó ta thay thế fetch() bằng bản của mình, gửi các request “không domain” tới đúng URL. Nếu có chỉ định domain thì mọi thứ giữ nguyên. - 4. Click vào liên kết hoạt động đúng
Nếu liên kết mở ngay bên trong iframe, ChatGPT sẽ không chấp thuận. Vì thế có thêm đoạn mã theo dõi click vào link và mở chúng ở cửa sổ ngoài qua openExternal(). - 5. Thiết lập head base (DEPRECATED)
Trước đây mã này thêm <base> vào <head>, nhưng giờ không còn hiệu lực. Sandbox sẽ reset mọi base được đặt, nên khuyến nghị dùng liên kết tuyệt đối cho tất cả: script, tài nguyên, font, API, v.v.
5. Máy chủ MCP: app/mcp/route.ts
Giờ chuyển sang nửa còn lại của “Janus hai mặt” — máy chủ giao tiếp với ChatGPT qua MCP.
Tệp app/mcp/route.ts là một Route Handler của App Router, nó:
- nhận HTTP request từ ChatGPT (thường là POST với JSON payload theo định dạng MCP),
- chuyển chúng tới máy chủ MCP (dựa trên @modelcontextprotocol/sdk hoặc một lớp bọc mỏng),
- trả về JSON response theo định dạng MCP.
Có hai hướng: bạn có thể viết với MCP SDK “thuần”, hoặc làm mượt trải nghiệm bằng vài lớp từ Next/Vercel.
Đây là phương án dùng TS MCP SDK “thuần”:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// 1. Tạo MCP server
const server = new McpServer({
name: "simple-mcp-server",
version: "1.0.0",
});
// 2. Đăng ký MCP Resources
// 3. Đăng ký MCP Tools
// 4. HTTP transport
const transport = new HttpServerTransport({
port: 3001,
path: "/mcp",
});
// 5. Khởi động server
await server.connect(transport);
Nhưng tốt hơn là dùng sẵn vài lớp để làm việc dễ chịu hơn:
// 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;
Ở đây McpGateway là lớp bọc quanh McpServer, bạn tạo nó ở đâu đó (ví dụ trong lib/mcp/server.ts) bằng SDK. Trong trường hợp của chúng ta nó gói gọn trong app/mcp/route.ts. Hãy cùng phân tích đầy đủ nội dung tệp này.
type ContentWidget
Ở đầu tệp chúng ta mô tả type ContentWidget. Nó chứa mọi dữ liệu của widget và được dùng ở hai nơi: khi đăng ký widget như mcp‑resource và khi mcp‑tool trả về metadata, trong đó chỉ định widget nào dùng để hiển thị dữ liệu đã trả về.
type ContentWidget = {
id: string; // Tên/key duy nhất
title: string; // Title
description: string; // Description
templateUri: string; // URI duy nhất của widget, có thể bất kỳ. Không ảnh hưởng gì.
invoking: string; // Dòng chữ phía trên widget khi đang tải
invoked: string; // Dòng chữ phía trên widget khi đã tải xong
html: string; // Toàn bộ mã html của widget.
widgetDomain: string; // "Tên miền" của widget. Không ảnh hưởng gì.
};
class McpGateway
Lớp bọc quanh McpServer, đơn giản hóa một số việc. Có 6 phương thức:
- initialize() — tải HTML của widget
- registerResources() — đăng ký widget dưới dạng mcp‑resources
- registerTools() — đăng ký các hàm dưới dạng mcp‑tools
- widgetMeta() — trả về metadata của widget
- getAppsSdkCompatibleHtml() — tải html của widget và patch nhẹ
- makeImgUrlsAbsolute() — patch html: chuyển link ảnh thành tuyệt đối
Hãy đi sâu hơn từng mục:
public async initialize()
Phương thức này tải html của các widget từ Internet và điền đối tượng kiểu ContentWidget.
{
id: "hello_world", // Key duy nhất của widget
templateUri: "ui://widget/hello_world.html", // URI duy nhất của widget. "ui:" không có ý nghĩa gì.
title: "HelloWorld Widget", // Tên widget
description: "Displays the HelloWorld widget", // Giải thích cho LLM widget làm gì
invoking: "Loading widget...", // Dòng chữ phía trên khi widget đang tải
invoked: "Widget loaded", // Dòng chữ phía trên khi widget đã tải xong
html: htmlWidget, // HTML của widget
widgetDomain: baseURL, // "Tên miền" của widget. Hiện chưa ảnh hưởng gì.
}
public registerResources()
Đăng ký các widget như mcp‑resources. Gọi phương thức server.registerResource(), trong đó truyền 4 tham số:
- id/key của MCP‑resource
- URI của resource (cần cho giao thức MCP; với widget thực chất là địa chỉ duy nhất)
- Metadata của MCP‑resource
- Hàm trả về MCP‑resource
Metadata của widget
{
title: widget.title, // Tên resource/widget
description: widget.description, // Mô tả resource/widget
mimeType: "text/html+skybridge", // Quan trọng! Chỉ html như vậy mới hiển thị như widget
_meta: {
"openai/widgetDescription": widget.description, // Mô tả widget
"openai/widgetPrefersBorder": true, // Bảo ChatGPT vẽ viền cho widget
},
}
Widget dưới dạng MCP‑resource
{
uri: uri.href, // URI của chúng ta (lấy từ tham số uri)
mimeType: "text/html+skybridge", // Quan trọng! Chỉ html như vậy mới hiển thị như widget
text: widget.html, // HTML của widget
_meta: {
"openai/widgetDescription": widget.description, // Mô tả widget
"openai/widgetPrefersBorder": true, // Bảo ChatGPT vẽ viền cho widget
"openai/widgetDomain": widget.widgetDomain, // "Tên miền" của widget. Hiện chưa ảnh hưởng gì.
"openai/widgetCSP": { // Quan trọng! Các domain widget được phép dùng:
connect_domains: [ // Domain cho kết nối (fetch, v.v.)
baseURL,
"https://codegym.cc",
],
resource_domains: [ // Domain cho tài nguyên (css/fonts/img)
baseURL,
"https://codegym.cc",
"https://cdn.tailwindcss.com",
"https://persistent.oaistatic.com",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com"
]
}
},
}
Sau này chúng ta sẽ còn chạm tới openai/widgetCSP nhiều lần, nhưng hiện tại muốn lưu ý 2 điểm:
- connect_domains — danh sách domain cho:
- fetch()
- tải script
- openExternal()
- resource_domains — danh sách domain cho:
- hình ảnh
- CSS
- font
Về lý thuyết bạn có thể liệt kê 200 domain, nhưng liệu với danh sách như vậy bạn có qua được review hay không — lại là câu chuyện khác.
Tôi cũng xem qua các tham số này ở những ứng dụng đã xuất bản và thấy có amplitude.com. Đây cũng là tín hiệu tích cực. Phân tích tốt luôn hữu ích.
public registerTools()
Đăng ký các hàm như mcp‑tools. Gọi phương thức server.registerTool(), trong đó truyền 3 tham số:
- id/key của MCP‑tool
- Metadata của MCP‑tool
- Hàm trả về MCP‑tool
Metadata của công cụ
Mọi tham số trong danh sách này đều quan trọng. Tôi sẽ nói kỹ hơn ở các bài giảng sau.
{
title: widget.title, // Tên công cụ
description: "Returns HelloWorld widget", // Quan trọng! Mô tả công cụ làm gì
inputSchema: z.object({}).describe("No inputs"), // Schema tham số của công cụ. Có thể dùng Zod
_meta: this.widgetMeta(widget), // Metadata của widget: hiển thị widget nào
annotations: {
destructiveHint: false, // Thao tác quan trọng - cần confirm
openWorldHint: false, // Thao tác thay đổi thứ gì đó ở dịch vụ bên thứ ba
readOnlyHint: true // Không thay đổi gì
},
}
Hàm thực hiện công việc chính
async (input, extra) => {
// 1. Kiểm tra tham số đầu vào
// 2. Làm điều gì đó quan trọng
return {
content: [{ type: "text", text: "HelloWorld MCP-tool" }], // Mô tả kết quả cho AI
structuredContent: { // Quan trọng! Đây chính là JSON kết quả.
timestamp: new Date().toISOString() // Có thể chứa bất kỳ dữ liệu nào.
},
_meta: this.widgetMeta(widget), // Metadata của widget hiển thị JSON
}; // Có thể vắng mặt - khi đó sẽ không có widget
}
private widgetMeta(widget: ContentWidget)
Trả về metadata của widget — dựa vào đó ChatGPT xác định widget nào dùng để hiển thị JSON kết quả.
{
"openai/outputTemplate": widget.templateUri, // URI của widget
"openai/toolInvocation/invoking": widget.invoking, // Dòng chữ khi widget đang tải
"openai/toolInvocation/invoked": widget.invoked, // Dòng chữ khi widget đã tải xong
"openai/widgetAccessible": true, // Có thể gọi MCP-tool từ widget
"openai/resultCanProduceWidget": true, // MCP-tool sẽ trả về widget
}
Muốn nói riêng về một điều đơn giản là "openai/outputTemplate". Trong giao thức MCP có 3 thực thể (bạn sẽ biết chi tiết ở mô‑đun 6):
- MCP Resources
- MCP Templates
- MCP Tools
Và "openai/outputTemplate" này không liên quan gì tới MCP Templates. MCP Templates hoàn toàn không được dùng trong ChatGPT Apps. Từ “template” ở đây có nguồn gốc như sau:
Widget được thiết kế như một mẫu để hiển thị JSON. MCP‑tool trả về một JSON nào đó, AI hiển thị widget, truyền JSON cho nó qua tham số ToolOutput, và widget hiển thị JSON đó một cách đẹp đẽ. outputTemplate — đơn giản là từ đồng nghĩa với widget.
Tôi nghĩ tạm vậy là đủ. Chúng ta sẽ phân tích kỹ hơn ở mô‑đun 4: mô tả tool, JSON Schema và handler như thế nào. Hiện tại chỉ cần hiểu: mọi thứ liên quan tới công cụ (tools) và logic — hãy tìm gần app/mcp/route.ts.
6. Cấu hình và “chất keo”: next.config.ts, middleware.ts, .env và đồng bọn
Giờ hãy xem bộ tệp chính giúp dự án Next.js của bạn hoạt động đúng bên trong iframe của ChatGPT và có thể truy cập được bởi ChatGPT qua đường hầm HTTPS (ngrok, Cloudflare Tunnel, v.v.; về tunnel chúng ta sẽ bàn riêng).
next.config.ts
Ngoài các thiết lập mặc định của Next.js, tệp này thường cấu hình:
- assetPrefix — để static (JS, CSS từ /_next/) được tải không phải từ domain của ChatGPT mà từ dev‑URL của bạn (tunnel hoặc Vercel);
- mọi thiết lập đặc thù mà template cần (ví dụ cờ experimental cho Next 16).
Trên thực tế, đây là một export nextConfig với các field cần thiết. Với bài giảng này, điều quan trọng là: nếu widget trong ChatGPT không thể tải CSS/JS, rất thường thủ phạm là assetPrefix.
proxy.ts (trước đây là middleware.ts)
Tệp này chèn một lớp middleware giữa request từ ChatGPT và các route của bạn. Trong template nó thường:
- thiết lập các CORS header, để iframe của ChatGPT có quyền gọi tới server của bạn;
- đôi khi cấu hình thêm header cho React Server Components.
Chưa cần biết hết mọi chi tiết. Chỉ cần nhớ: nếu ChatGPT phàn nàn về CORS hoặc bạn thấy lỗi lạ trong DevTools về quyền truy cập, hãy xem proxy.ts.
.env
Tệp .env (hoặc .env.local) — nơi để bí mật và biến môi trường:
- OPENAI_API_KEY (nếu máy chủ MCP tự gọi OpenAI API),
- địa chỉ các API nội bộ,
- token dịch vụ bên thứ ba, v.v.
Có một lưu ý quan trọng: trong Next.js, các biến bắt đầu bằng NEXT_PUBLIC_ sẽ tự động được đưa vào JS bundle và có thể truy cập từ trình duyệt. Không bao giờ làm vậy với OPENAI_API_KEY; bí mật phải chỉ là biến phía server.
package.json và tsconfig.json
Trong package.json bạn sẽ thấy:
- phiên bản Next.js, React, Apps SDK, MCP SDK và các phụ thuộc khác;
- các script dev, build, start, và đôi khi cả lệnh hỗ trợ (linter, formatter, v.v.).
Trong tsconfig.json là các thiết lập quen thuộc của TypeScript:
- đường dẫn alias (@/lib, @/components),
- chế độ strict,
- mục tiêu biên dịch (target).
Ở góc độ khóa học này, điều chính là hiểu rằng template sử dụng stack TypeScript thông thường, và bạn có thể mở rộng theo cách tiêu chuẩn.
7. “Điều hướng nhanh” trong dự án cho lập trình viên
Hãy cố định lại nơi cần đến khi bạn muốn làm các việc điển hình. Không theo danh sách, chỉ là các mini‑kịch bản.
Nếu bạn muốn thay đổi text/nút trong widget, hãy mở tệp UI của widget: hoặc là app/widget/page.tsx, hoặc app/page.tsx — tùy template. Ở đó bạn chỉnh JSX, thêm component mới, kết nối design system. Và chính tại đây bạn sẽ dùng runtime của Apps SDK (window.openai hoặc các hook tiện lợi) để hiển thị dữ liệu.
Nếu cần thêm một nút mới làm việc trên server, bạn vẫn bắt đầu từ tệp UI. Nút trong widget khi click sẽ gọi window.openai.callTool, còn phần hiện thực của công cụ này bạn thêm vào cấu hình của máy chủ MCP, tức là mã nằm gần app/mcp/route.ts. Liên kết UI ↔ logic của tool chúng ta sẽ học ở mô‑đun 4 và tiếp theo.
Khi bạn muốn dạy ChatGPT chức năng mới (ví dụ “tìm tour” hay “gợi ý sản phẩm”), hãy tới lớp MCP (các tệp được import từ app/mcp/route.ts). Ở đó đăng ký tool mới với JSON Schema, mô tả và handler. Widget sau đó có thể đọc kết quả qua window.openai.toolOutput và hiển thị đẹp mắt.
Nếu static bị lỗi hoặc widget hiển thị kỳ lạ chỉ trong ChatGPT, còn local thì bình thường, hãy nhớ tới lớp “kết dính”. Trước hết kiểm tra next.config.ts (đặc biệt assetPrefix) và middleware.ts/proxy.ts (CORS). Nếu bạn vừa đổi tunnel, URL hoặc deploy lên Vercel, sự chính xác của các thiết lập này là tối quan trọng.
Cuối cùng, nếu bạn nghi ngờ vấn đề với khóa hoặc môi trường, bộ ba tệp của bạn là — .env.local, package.json (để chắc chắn phiên bản phụ thuộc và script đang dùng) và log của dev server. Chính bộ này đảm bảo MCP có quyền truy cập tới các bí mật và dịch vụ cần thiết.
8. Thực hành nhỏ: làm quen hệ thống tệp bằng tay
Lý thuyết là lý thuyết, nhưng hãy thực hành để cố định vị trí các thành phần. Bạn có thể làm ngay trong editor/IDE.
Hãy mở thư mục app trong dự án và tìm tệp nào chịu trách nhiệm cho widget. Nếu template dùng app/page.tsx, chính ở đó bạn sẽ thấy dòng chữ quen thuộc kiểu “HelloWorld — ChatGPT App” hoặc một đoạn chào mừng. Nếu không có thư mục widget riêng, hãy mở app/page.tsx và đảm bảo có 'use client' và một ít JSX.
Tiếp theo tìm app/mcp/route.ts. Chú ý những module nó import: thường bạn sẽ thấy hoặc dùng MCP SDK trực tiếp, hoặc gọi tới một hàm tiện ích trong lib/mcp/*. Hãy đánh giá lớp đệm này “mỏng” tới đâu — lý tưởng là gần như không có business logic, chỉ “nhận JSON → chuyển cho server → trả JSON”.
Sau đó vào next.config.ts và proxy.ts/middleware.ts. Không cần hiểu hết mọi thứ trong đó, chỉ cần cố định rằng:
- next.config.ts chịu trách nhiệm config của Next, bao gồm cả quy tắc build và cấp phát asset;
- proxy.ts can thiệp vào HTTP request (gần như chắc chắn bạn sẽ thấy xử lý header ở đó).
Cuối cùng mở .env hoặc .env.local và đảm bảo các khóa của bạn nằm ở đó, không nằm trong mã. Nếu ở đâu đó bạn thấy NEXT_PUBLIC_OPENAI_API_KEY — đây là lúc tốt để sửa khi còn đang ở giai đoạn phát triển local.
9. Sơ đồ trực quan: ChatGPT tương tác với template của bạn như thế nào
Để bức tranh trọn vẹn, hãy nhìn vào luồng đơn giản sau:
flowchart TD
U[Người dùng trong ChatGPT] -->|Nhập yêu cầu| M[Mô hình ChatGPT]
M -->|Gọi tool| MCP["MCP endpoint của bạn
app/mcp/route.ts"]
MCP -->|"Phản hồi JSON MCP (structuredContent, _meta, liên kết UI)"| M
M -->|Quyết định hiển thị UI| WIDGET_URL["URL của widget
(/widget hoặc /)"]
WIDGET_URL -->|iframe| W[Widget của bạn
app/page.tsx]
W -->|đọc window.openai.toolOutput
+ widgetState| U
Ở đây cần để ý rằng bên khởi xướng gần như luôn là mô hình ChatGPT, chứ không phải trình duyệt của người dùng, như trong web app truyền thống. app/mcp/route.ts và app/widget/page.tsx — chỉ là hai “cánh cửa” khác nhau vào cùng một dự án Next.js: một cho “robot” (MCP), một cho UI.
Nếu giữ trong đầu “bản đồ” này (widget → lớp MCP → config) và chủ động tránh những “cái bẫy” vừa nêu, các phần sau của khóa học bạn sẽ có thể tập trung vào logic và UX của App, thay vì đi tìm “cái tệp gây lỗi”.
10. Lỗi thường gặp khi làm việc với cấu trúc template
Lỗi số 1: Nhầm widget với trang web thông thường.
Đôi khi lập trình viên thấy trong template có cả app/page.tsx lẫn app/widget/page.tsx, sửa “nhầm” tệp và ngạc nhiên vì sao thay đổi không xuất hiện trong ChatGPT. Widget — chính là trang được dùng làm outputTemplate/iframe cho MCP‑tool. Nếu bạn đổi route khác, ChatGPT sẽ không hề biết. Luôn xem README của template và kiểm tra URL nào được chỉ định làm widget.
Lỗi số 2: Viết mã phía client (window, document) trong các tệp server của MCP.
Tệp app/mcp/route.ts và mọi thứ nó import đều chạy trên server. Bất kỳ nỗ lực nào dùng window hay DOM API ở đó sẽ khiến runtime sập. Nếu muốn làm gì ở UI, gần như chắc chắn nó phải nằm trong các tệp dưới app/widget hoặc các client component khác. Lớp MCP — backend thuần: request, database, API ngoài và tạo phản hồi có cấu trúc.
Lỗi số 3: Bỏ qua assetPrefix và cấu hình CORS.
Ở local localhost:3000 mọi thứ hoạt động rất ổn, nhưng mở App qua tunnel trong ChatGPT — style biến mất, JS không tải, console đầy lỗi CORS. Thường lý do là cấu hình trong next.config.ts hoặc middleware.ts/proxy.ts không tính tới URL công khai mới hoặc vô tình bị hỏng khi refactor. Khi sửa các tệp này, luôn nhớ rằng mã của bạn sẽ sống trong iframe trên domain của ChatGPT, chứ không trực tiếp ở localhost.
Lỗi số 4: Lưu bí mật không ở .env mà đặt thẳng trong mã hoặc trong biến NEXT_PUBLIC_*.
Cất OPENAI_API_KEY trong const apiKey = 'sk-...' đâu đó trong app/widget/page.tsx — là ý tưởng tệ nhất: khóa sẽ nằm trong JS bundle và tới tay bất cứ người dùng nào. Gần như tệ không kém — tạo biến NEXT_PUBLIC_OPENAI_API_KEY, vì prefix NEXT_PUBLIC_ đảm bảo nó vào trình duyệt. Luôn đặt bí mật trong .env không có prefix này và chỉ dùng ở phía server (máy chủ MCP, backend function).
Lỗi số 5: Cho rằng template “quá thông minh” và ngại đụng vào.
Đôi khi lập trình viên xem starter chính thức như thứ “bất khả xâm phạm”: “tốt nhất đừng đụng vào, lỡ vỡ tích hợp”. Hệ quả là họ viết toàn bộ mã của mình ở đâu đó bên ngoài, làm phức tạp kiến trúc và vẫn giẫm phải cùng một loạt rắc rối. Thực tế template — chỉ là một dự án Next.js được sắp xếp gọn gàng với vài thiết lập cho Apps SDK. Hiểu rằng app/ — là UI và MCP, còn lại là config bình thường sẽ “giải phóng” bạn: bạn bắt đầu làm việc với mã như một dự án React/Next quen thuộc, chứ không phải “hộp ma thuật”.
Lỗi số 6: Cố giải quyết mọi thứ “ở cấp widget”.
Đôi khi muốn làm tất cả trong UI: cả business logic, truy cập database, gọi API ngoài. Trong bối cảnh ChatGPT Apps, đây là ý tưởng đặc biệt tệ: widget sống trong sandbox rất chặt, không thấy bí mật của bạn và phụ thuộc nhiều vào window.openai. Nếu cần làm việc “nghiêm túc” — chỗ của nó là ở lớp MCP và các dịch vụ backend; còn widget nên là lớp trình bày mỏng, hiển thị dữ liệu có cấu trúc và, khi cần, kích hoạt tool.
GO TO FULL VERSION