DEV Community

Mohamed Bal
Mohamed Bal

Posted on

Build a Streaming AI Chatbot with Next.js, Vercel AI SDK, and DEVUP AI

DEVUP AI streaming chatbot built with Next.js and the Vercel AI SDK, featuring secure server-side credentials, live responses, and local DZD billing.

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.

Streaming chatbot request flow from the browser through a protected Next.js server route to the DEVUP AI public API, with streamed responses returned to the user interface.

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
Enter fullscreen mode Exit fullscreen mode

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:

  • streamText for streamed language-model output;
  • useChat for client-side conversation state;
  • DefaultChatTransport for 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 fullscreen mode Exit fullscreen mode

Enter the project directory:

cd devup-streaming-chat
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The imports we will use are intentionally narrow:

import { createDevupAI } from "devupai/ai";
import { streamText } from "ai";
import { useChat } from "@ai-sdk/react";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If the command prints .env.local, Git is ignoring it.

4. Build the streaming server route

Create:

src/app/api/chat/route.ts
Enter fullscreen mode Exit fullscreen mode

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 },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Open:

http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Send a message.

If the integration is working correctly, you should observe this sequence:

  1. the UI enters submitted;
  2. the server validates the request;
  3. the route starts streamText;
  4. the UI enters streaming;
  5. text parts grow progressively;
  6. 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.

Production safety checklist for a streaming AI chatbot, covering server-side API keys, input validation, access control, masked errors, rate limiting, and recovery 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.local is in the project root;
  • the variable name is exactly DEVUP_API_KEY;
  • you restarted npm run dev after 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 DefaultChatTransport against /api/chat;
  • the UI renders text from message.parts;
  • the installed ai and @ai-sdk/react versions 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. authenticated chat sessions;
  2. persisted message history;
  3. structured outputs for application data;
  4. application-defined tools;
  5. file or image inputs;
  6. model selection controlled by server policy;
  7. per-user usage reporting;
  8. 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

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)