Build a chat page
A working chat UI with sign-in, a model picker, and streamed responses, in one component.
This guide builds the smallest useful chat page: the sign-in widget, a model picker filled from the account, a prompt box, and a streamed answer. It is a trimmed-down version of the playground that ships in the repo:

You need the quickstart done first, so the handler is
mounted at /api/chatgpt/*.
The component
"use client";
import { ChatGPTProxyError, createChatGPTProxyProvider } from "@opencoredev/loginwithchatgpt-ai";
import { LoginWithChatGPT } from "@opencoredev/loginwithchatgpt-react";
import { streamText } from "ai";
import { useEffect, useState } from "react";
// Module-level: one provider per page load.
const chatgpt = createChatGPTProxyProvider();
export function Chat() {
const [models, setModels] = useState<string[]>([]);
const [model, setModel] = useState("");
const [prompt, setPrompt] = useState("");
const [answer, setAnswer] = useState("");
const [running, setRunning] = useState(false);
const [needsLogin, setNeedsLogin] = useState(false);
useEffect(() => {
let active = true;
chatgpt
.listModels()
.then((list) => {
if (!active) return;
setModels(list);
setModel(list.includes("gpt-5.5") ? "gpt-5.5" : (list[0] ?? ""));
setNeedsLogin(false);
})
.catch((error) => {
if (!active) return;
// 401 means there is no session yet. Show the sign-in button.
if (error instanceof ChatGPTProxyError && error.status === 401) {
setNeedsLogin(true);
}
});
return () => {
active = false;
};
}, []);
async function send() {
if (running || !model || !prompt.trim()) return;
setRunning(true);
setAnswer("");
try {
const result = streamText({ model: chatgpt(model), prompt });
for await (const delta of result.textStream) {
setAnswer((current) => current + delta);
}
} finally {
setRunning(false);
}
}
if (needsLogin) {
return (
<LoginWithChatGPT
consent={{ appName: "Acme" }}
onAuthenticated={() => window.location.reload()}
/>
);
}
return (
<div>
<select value={model} onChange={(e) => setModel(e.target.value)}>
{models.map((slug) => (
<option key={slug}>{slug}</option>
))}
</select>
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<button type="button" onClick={() => void send()} disabled={running}>
{running ? "Streaming…" : "Send"}
</button>
<pre>{answer}</pre>
</div>
);
}That is the whole loop. Three things carry it:
listModels()doubles as the session check. AChatGPTProxyErrorwith status 401 means nobody is signed in, so the page swaps to the widget.streamText({ model: chatgpt(model), prompt })goes to your/api/chatgpt/responsesproxy. The browser never sees a token.result.textStreamis an async iterable of text deltas. Appending them to state is all the streaming UI you need to start.
Add a Fast toggle and thinking level
Both are request headers the server validates. Wire them to two selects and
pass them into streamText:
const result = streamText({
model: chatgpt(model),
prompt,
headers: {
...(fast ? { "x-login-with-chatgpt-service-tier": "fast" } : {}),
"x-login-with-chatgpt-reasoning-effort": thinking, // "low" | "medium" | "high"
},
});Only show the Fast toggle for models that support it, and say that it burns included usage faster. Details in Fast tier & thinking effort.
Or run the finished version
The repo ships the full playground from the screenshot above, with attachments, a fast toggle, thinking levels, and error states:
git clone https://github.com/loginwithchatgpt/login-with-chatgpt
cd login-with-chatgpt
bun install
bun run demo # http://localhost:3000Its source is in examples/demo/src/frontend.tsx and is a good reference for
handling edge cases this guide skips: attachment size limits, model-list
fallbacks, and surfacing stream errors.