Disclosure: I am the founder of DEVUP AI. Next.js and the Vercel AI SDK are third-party projects. DEVUP AI is an independent platform and is not affiliated with or endorsed by their publishers.
Most AI chatbot tutorials stop when they receive a complete string from an API.
That is enough for a proof of concept. It is not enough for a responsive product.
A production-shaped chat experience should begin rendering while the answer is still being generated. It should keep credentials out of the browser, expose clear loading and recovery states, and fail without leaking server details.
In this tutorial, we will build exactly that:
- a Next.js App Router application;
- a server-only connection to DEVUP AI;
- real-time message streaming with the Vercel AI SDK;
- a typed React chat interface using
useChat; - stop, retry, loading, and error states;
- a practical checklist for protecting the application route;
- local DZD billing through the DEVUP AI account.
The result is intentionally small enough to understand in one sitting, but structured well enough to become the foundation of a real application.
What we are building
The browser never receives the DEVUP AI API key.
It sends chat messages to a Next.js Route Handler. The server selects a model, starts the generation, and returns a UI message stream. React renders each arriving text part immediately.
The browser communicates with the application route; the DEVUP AI credential remains server-side.
The public integration flow is deliberately simple:
Chat UI → Next.js server route → DEVUP AI public API
This tutorial does not depend on any private platform implementation detail.
Why use the Vercel AI SDK here?
You could manually parse an HTTP stream, design your own event protocol, manage partial messages, and implement cancellation from scratch.
For most product teams, that is unnecessary work.
The Vercel AI SDK already provides:
-
streamTextfor streamed language-model output; -
useChatfor client-side conversation state; -
DefaultChatTransportfor sending messages to an application route; - structured UI message parts;
- status values for submitted, streaming, ready, and error states;
- cancellation and regeneration controls.
DEVUP AI exposes a first-party devupai/ai provider that implements the AI SDK language-model interface. The application can therefore use the standard AI SDK primitives instead of writing a custom streaming parser.
Prerequisites
You need:
- Node.js 18 or newer;
- a DEVUP AI account;
- a DEVUP AI API key;
- one model identifier copied from the public model catalog;
- basic familiarity with React and TypeScript.
Create the API key from the DEVUP AI dashboard. Treat it like a password: never paste it into client-side code, screenshots, commits, or public issue reports.
1. Create the Next.js project
Run:
npx create-next-app@latest devup-streaming-chat \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
Enter the project directory:
cd devup-streaming-chat
This tutorial assumes the src/ directory and App Router are enabled.
2. Install the SDK packages
Install the DEVUP AI SDK, the AI SDK, its React hooks, and the compatible provider peer dependency:
npm install devupai ai @ai-sdk/react @ai-sdk/openai-compatible
The imports we will use are intentionally narrow:
import { createDevupAI } from "devupai/ai";
import { streamText } from "ai";
import { useChat } from "@ai-sdk/react";
3. Configure server-only environment variables
Create .env.local in the project root:
DEVUP_API_KEY=replace_with_your_private_key
DEVUP_MODEL_ID=replace_with_a_model_id_from_the_catalog
Do not prefix either variable with NEXT_PUBLIC_.
In Next.js, variables using that prefix are intended to be exposed to browser code. Our key must only be read inside the server route.
Also confirm that .env.local is ignored by Git:
git check-ignore .env.local
If the command prints .env.local, Git is ignoring it.
4. Build the streaming server route
Create:
src/app/api/chat/route.ts
Add the following code:
import { createDevupAI } from "devupai/ai";
import {
convertToModelMessages,
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
type UIMessage,
} from "ai";
export const maxDuration = 30;
const apiKey = process.env.DEVUP_API_KEY;
const modelId = process.env.DEVUP_MODEL_ID;
if (!apiKey) {
throw new Error("DEVUP_API_KEY is not configured.");
}
if (!modelId) {
throw new Error("DEVUP_MODEL_ID is not configured.");
}
const devupai = createDevupAI({ apiKey });
export async function POST(request: Request) {
try {
const body: unknown = await request.json();
if (
typeof body !== "object" ||
body === null ||
!("messages" in body) ||
!Array.isArray(body.messages) ||
body.messages.length === 0 ||
body.messages.length > 40
) {
return Response.json(
{ error: "Invalid chat request." },
{ status: 400 },
);
}
const messages = body.messages as UIMessage[];
const result = streamText({
model: devupai(modelId),
instructions:
"You are a concise, helpful assistant. Answer clearly and do not invent facts.",
messages: await convertToModelMessages(messages),
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({
stream: result.stream,
onError: () => "The response could not be completed.",
}),
});
} catch {
return Response.json(
{ error: "The request could not be processed." },
{ status: 400 },
);
}
}
What this route is doing
1. It reads secrets only on the server
DEVUP_API_KEY and DEVUP_MODEL_ID are evaluated inside a Route Handler. Neither value is included in the client bundle.
2. It rejects obviously invalid payloads
The example requires a non-empty messages array and applies a simple message-count boundary.
This is not complete schema validation, but it establishes the correct boundary: browser input is untrusted and must be checked before use.
3. It converts UI messages to model messages
useChat works with UIMessage objects containing typed parts. convertToModelMessages converts that UI representation into the format expected by streamText.
4. It starts generation without waiting for the full answer
streamText returns a stream. The Route Handler converts it to the protocol expected by the AI SDK UI layer.
5. It masks stream errors
The browser receives a generic error string instead of an exception, upstream response, credential, or internal server detail.
That boundary matters. Useful diagnostic information belongs in protected server-side monitoring—not in a public response.
5. Build the React chat interface
Replace src/app/page.tsx with:
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useState, type FormEvent } from "react";
export default function Home() {
const [input, setInput] = useState("");
const {
messages,
sendMessage,
status,
stop,
regenerate,
error,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
});
const isBusy = status === "submitted" || status === "streaming";
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = input.trim();
if (!text || isBusy) {
return;
}
sendMessage({ text });
setInput("");
}
return (
<main className="min-h-screen bg-slate-950 px-4 py-10 text-slate-100">
<section className="mx-auto flex min-h-[80vh] w-full max-w-3xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-slate-900 shadow-2xl shadow-fuchsia-950/20">
<header className="border-b border-white/10 px-6 py-5">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-fuchsia-400">
DEVUP AI × Next.js
</p>
<h1 className="mt-1 text-2xl font-semibold">
Streaming Chat
</h1>
</div>
<span
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300"
aria-live="polite"
>
{status}
</span>
</div>
</header>
<div
className="flex-1 space-y-5 overflow-y-auto px-6 py-6"
aria-live="polite"
>
{messages.length === 0 && (
<div className="mx-auto mt-20 max-w-md text-center">
<div className="mx-auto mb-5 h-14 w-14 rounded-2xl bg-gradient-to-br from-orange-400 via-pink-500 to-violet-700" />
<h2 className="text-xl font-semibold">Start a conversation</h2>
<p className="mt-2 text-sm leading-6 text-slate-400">
Your API key remains on the server. Responses appear as they
are generated.
</p>
</div>
)}
{messages.map((message) => {
const text = message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
const isUser = message.role === "user";
return (
<article
key={message.id}
className={`flex ${isUser ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-4 py-3 text-sm leading-6 ${
isUser
? "bg-gradient-to-r from-orange-500 via-pink-600 to-violet-700 text-white"
: "border border-white/10 bg-white/5 text-slate-200"
}`}
>
{text}
</div>
</article>
);
})}
{status === "submitted" && (
<p className="text-sm text-slate-400">Starting the response…</p>
)}
{error && (
<div className="rounded-2xl border border-red-400/20 bg-red-400/10 p-4">
<p className="text-sm text-red-200">
Something went wrong. No server details were exposed.
</p>
<button
type="button"
onClick={() => regenerate()}
className="mt-3 rounded-xl bg-red-300 px-3 py-2 text-sm font-semibold text-red-950"
>
Retry last message
</button>
</div>
)}
</div>
<form
onSubmit={handleSubmit}
className="border-t border-white/10 bg-slate-950/60 p-4"
>
<div className="flex items-end gap-3">
<label className="sr-only" htmlFor="message">
Message
</label>
<textarea
id="message"
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
placeholder="Ask anything…"
rows={1}
disabled={isBusy}
className="min-h-12 flex-1 resize-none rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm outline-none transition placeholder:text-slate-500 focus:border-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-60"
/>
{isBusy ? (
<button
type="button"
onClick={() => stop()}
className="h-12 rounded-2xl border border-white/10 bg-white/10 px-5 text-sm font-semibold hover:bg-white/15"
>
Stop
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="h-12 rounded-2xl bg-gradient-to-r from-orange-500 via-pink-600 to-violet-700 px-5 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
>
Send
</button>
)}
</div>
</form>
</section>
</main>
);
}
6. Understand the client lifecycle
The important part of the UI is not the gradient. It is the state model.
useChat exposes four relevant statuses:
| Status | Meaning | Recommended UI behavior |
|---|---|---|
submitted |
The message was sent, but the first response chunk has not arrived | Show a lightweight waiting state |
streaming |
Response chunks are arriving | Render partial text and expose Stop |
ready |
The response completed | Enable the next message |
error |
The request failed | Show a generic error and Retry |
The component also renders message.parts instead of relying on a single legacy content string. That matters because the parts model can later represent more than text without forcing you to redesign the entire conversation state.
7. Run the application
Start the development server:
npm run dev
Open:
http://localhost:3000
Send a message.
If the integration is working correctly, you should observe this sequence:
- the UI enters
submitted; - the server validates the request;
- the route starts
streamText; - the UI enters
streaming; - text parts grow progressively;
- the UI returns to
ready.
This sequence is more important than raw generation speed. Streaming reduces the time before the user sees useful output and provides continuous feedback that the application is working.
8. Verify cancellation and recovery
Do not test only the happy path.
Stop a response
Ask for a longer answer, then press Stop while it is streaming.
The browser request should be aborted and the UI should become usable again.
Test the error state
Temporarily set an invalid model identifier in .env.local, restart the development server, and send a message.
The interface should show the generic error state. It should not display a stack trace, credential, raw response body, or internal diagnostic message.
Restore the correct model identifier and test Retry.
9. Production safety checklist
The example establishes a safe direction, but a public application needs additional controls.
Secure the application layer before exposing the chat route to real traffic.
Keep the key server-only
The browser should call your application route, never DEVUP AI with a private project key embedded in JavaScript.
Validate the complete request schema
Before production, replace the lightweight array check with a schema that validates:
- allowed message roles;
- supported part types;
- maximum message count;
- maximum text length;
- attachment types and sizes, if enabled.
Authenticate the application user
An API key protects the platform account. It does not identify the person using your application.
If your chatbot is not public, require an authenticated user before starting generation.
Rate-limit the application route
Apply limits by authenticated user or another stable application-level identity. Do not rely only on a shared deployment IP.
Keep public errors generic
Return safe messages to users. Send sanitized diagnostic signals to your private monitoring system.
Avoid logging secrets and full conversations
Do not log authorization headers, environment variables, or complete prompts by default. If conversations may contain sensitive information, define an explicit retention policy before adding persistence or analytics.
Set deliberate time and size boundaries
Bound request size, conversation depth, generation duration, and any tool execution. Unbounded inputs become reliability and cost risks.
10. Common problems
DEVUP_API_KEY is not configured
Check that:
-
.env.localis in the project root; - the variable name is exactly
DEVUP_API_KEY; - you restarted
npm run devafter editing the file.
The model cannot be resolved
Copy the model identifier exactly from the DEVUP AI catalog and assign it to DEVUP_MODEL_ID. Do not guess an identifier from a display name.
The request finishes, but nothing renders
Confirm that:
- the route returns an AI SDK UI message stream;
- the client uses
DefaultChatTransportagainst/api/chat; - the UI renders text from
message.parts; - the installed
aiand@ai-sdk/reactversions are compatible.
TypeScript rejects the provider
Update the related packages together:
npm install devupai@latest ai@latest @ai-sdk/react@latest @ai-sdk/openai-compatible@latest
Then restart the TypeScript server and the Next.js development process.
The browser displays a detailed backend failure
Do not forward raw exception messages from the stream. Map them to a generic public string and inspect the actual failure only in protected server-side diagnostics.
11. What to add next
Once this baseline is stable, extend it one capability at a time:
- authenticated chat sessions;
- persisted message history;
- structured outputs for application data;
- application-defined tools;
- file or image inputs;
- model selection controlled by server policy;
- per-user usage reporting;
- automated integration tests for streaming and cancellation.
Avoid adding all of them at once. A small, observable system is easier to secure and debug than a feature-rich black box.
Local billing is part of the developer experience
For Algerian developers, integration quality is only one part of accessibility. Payment rails can stop a project before the first production request.
DEVUP AI combines a public developer API with local DZD billing, allowing teams to build and operate the application through a locally accessible account instead of depending on a foreign payment workflow.
No foreign-currency price is required in the code, and switching the selected model does not require rebuilding the chat transport.
A dedicated VS Code experience is also in development
DEVUP AI is building a dedicated VS Code extension designed for direct platform integration. The goal is a native developer workflow with model selection, streaming chat, project-aware tools, usage visibility, and secure configuration—without forcing developers to leave the editor.
The extension is still under development. This tutorial uses the public SDK integration available today.
Final result
You now have a small but serious streaming chatbot foundation:
- the credential remains on the server;
- messages use the current typed UI representation;
- the answer renders while it is generated;
- users can stop and retry;
- public errors stay generic;
- the model is configurable without changing application code;
- billing is handled locally in DZD through DEVUP AI.
The core lesson is simple:
A good AI integration is not only a successful API call. It is a controlled path from user input to a recoverable, observable, and secure product experience.
Official references
- DEVUP AI — Vercel AI SDK integration
- DEVUP AI documentation
- Vercel AI SDK — Chatbot guide
- Vercel AI SDK —
streamText - Vercel AI SDK — Transport
- Next.js — Route Handlers
If you are working primarily inside an editor, read the companion guide on connecting DEVUP AI to VS Code. For an agentic terminal workflow, see Claude Code in Algeria: A Practical Setup with Local DZD Billing.
What would you build first with a streaming AI endpoint: a support assistant, an internal search tool, or a product copilot?



Top comments (0)