Skip to content
AI Product Engineer Wiki

    ↑ ↓ to move between results, Enter to open, Esc to close

    Menu

    A day-one demo in Next.js

    Build a working AI feature demo in one day with Next.js and the Anthropic SDK, with a server route, streaming UI and handling for every stop reason.

    Level: Intermediate 10 min read Updated
    On this page

    Why this matters

    A prompt-first test proves the model can do the task. A demo that real users can click proves something more: that the experience works in their hands. With Next.js and the Anthropic SDK, you can get from an empty folder to a deployed, shareable demo in a day.

    The trick is to build the demo so it isn’t throwaway. A few decisions on day one, such as keeping the key on the server, versioning the prompt, handling every stop reason and logging each request, mean the demo can grow into the real feature instead of being rewritten. This article walks through that day.

    Project setup

    Create a Next.js app with the App Router and TypeScript, and add the SDK:

    Shell
    npx create-next-app@latest reply-demo --typescript --app --eslint
    cd reply-demo
    npm install @anthropic-ai/sdk

    Put your API key in .env.local, which Next.js loads on the server and create-next-app already ignores in git:

    .env.local
    ANTHROPIC_API_KEY=your-key-here

    Two rules keep the key safe:

    • Never prefix it with NEXT_PUBLIC_. That prefix tells Next.js to embed the value in the JavaScript sent to browsers, where anyone can read it.
    • Only create the Anthropic client in server code: route handlers, server actions or server-only modules. Never in a component marked 'use client'.

    The request path then looks like this:

    How a request flows through the demo. The browser never talks to the model API directly, so the key never leaves the server.
    Show text descriptionHide text description

    A sequence of 5 steps.

    1. Browser : Client component sends the user input to your route
    2. Route handler : Validates input, adds the versioned prompt, calls the API
    3. Claude API : Streams the response back to your server
    4. Route handler : Forwards text, then a final event with the stop reason; logs the request
    5. Browser : Renders text as it arrives, then the final state

    A streaming route handler

    The route below is the server side of the demo. It’s the same streaming pattern as in streaming and loading states, with three additions that pay off on day two: a prompt version recorded with every request, a log line per request with the outcome, tokens and timing, and a time limit so a stuck request can’t hang forever.

    app/api/draft/route.ts
    import Anthropic from '@anthropic-ai/sdk';
    
    const client = new Anthropic();
    
    // Bump on every prompt change. It goes into every log line, so you can compare versions later.
    const PROMPT_VERSION = 'reply-v3';
    const SYSTEM = 'Draft a concise, friendly reply to the customer message. Plain text, under 120 words, no greeting line.';
    const MODEL = 'claude-opus-5-5';
    const TIME_LIMIT_MS = 30_000;
    
    type StreamEvent =
      | { type: 'text'; text: string }
      | { type: 'done'; stopReason: Anthropic.Message['stop_reason'] }
      | { type: 'error'; retryable: boolean };
    
    export async function POST(request: Request): Promise<Response> {
      const { prompt } = (await request.json()) as { prompt?: unknown };
      if (typeof prompt !== 'string' || prompt.length === 0 || prompt.length > 8000) {
        return Response.json({ error: 'Prompt must be 1 to 8,000 characters.' }, { status: 400 });
      }
    
      const started = Date.now();
      // Cancel the model call if the user leaves or presses Stop, or if it takes longer than the limit.
      const signal = AbortSignal.any([request.signal, AbortSignal.timeout(TIME_LIMIT_MS)]);
      const encoder = new TextEncoder();
    
      const body = new ReadableStream<Uint8Array>({
        async start(controller) {
          const send = (event: StreamEvent) => controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
          let outcome = 'unknown';
          let usage: Anthropic.Usage | undefined;
          try {
            const stream = client.messages.stream(
              {
                model: MODEL,
                max_tokens: 4096,
                output_config: { effort: 'low' },
                system: SYSTEM,
                messages: [{ role: 'user', content: prompt }],
              },
              { signal },
            );
            for await (const event of stream) {
              if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
                send({ type: 'text', text: event.delta.text });
              }
            }
            const message = await stream.finalMessage();
            usage = message.usage;
            outcome = String(message.stop_reason);
            send({ type: 'done', stopReason: message.stop_reason });
          } catch (error) {
            outcome = request.signal.aborted ? 'cancelled' : signal.aborted ? 'timeout' : 'error';
            if (!request.signal.aborted) {
              const retryable =
                outcome === 'timeout' ||
                error instanceof Anthropic.RateLimitError ||
                error instanceof Anthropic.InternalServerError ||
                error instanceof Anthropic.APIConnectionError;
              send({ type: 'error', retryable });
            }
          } finally {
            // One structured line per request: enough to answer "what happened?" on day two.
            console.log(
              JSON.stringify({
                route: 'draft',
                promptVersion: PROMPT_VERSION,
                model: MODEL,
                outcome,
                ms: Date.now() - started,
                inputTokens: usage?.input_tokens,
                outputTokens: usage?.output_tokens,
              }),
            );
            try {
              controller.close();
            } catch {
              // Already closed because the client disconnected.
            }
          }
        },
      });
    
      return new Response(body, {
        headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8', 'Cache-Control': 'no-store' },
      });
    }

    Notice what the log line deliberately leaves out: the user’s text. For a demo with a handful of testers, you might log it too, with their consent. In production, follow your privacy rules and log behavior, not content.

    The client component

    Use the DraftReply component from streaming and loading states as components/draft-reply.tsx. It already renders streamed text, offers Stop, and maps every final event to a designed state. The page just puts it in context:

    app/page.tsx
    // Imports the DraftReply component from the streaming article; that file is type-checked there.
    import { DraftReply } from '../components/draft-reply';
    
    const sampleMessage = 'Hi, I was charged twice for my October invoice. Can you help?';
    
    export default function Page() {
      return (
        <main style={{ maxWidth: 640, margin: '2rem auto', padding: '0 1rem' }}>
          <h1>Support reply demo</h1>
          <p>Customer message:</p>
          <blockquote>{sampleMessage}</blockquote>
          <DraftReply customerMessage={sampleMessage} />
        </main>
      );
    }

    Run npm run dev and open http://localhost:3000. You have a working, streaming AI feature. Replace the sample message with an input box, or better, with a list of the real inputs from your prompt-first test, so testers react to realistic cases.

    Handling every stop reason

    The demo’s most important property isn’t visible on the happy path: every way a request can end is handled on purpose.

    How it ends Where it’s handled What the tester sees
    end_turn Route sends done; component shows the draft The draft and “Draft ready”
    max_tokens, context window filled Route sends done; component labels it “This draft was cut off…”
    refusal Route sends done; component explains A neutral message and a manual path
    User presses Stop Request aborted; route stops the model call The partial draft, marked as stopped
    Time limit reached Route’s timeout signal fires An error with “Try again”
    API or network error Route catches it and sends error An error, with “Try again” only if it can help
    Connection drops Component sees no final event An error, not a silently partial draft

    Handling these on day one costs an hour. Adding them later, after testers have seen confusing blank screens, costs credibility.

    Sharing it safely

    A deployed demo calls a paid API on every click, so don’t publish it openly:

    • Restrict access. Share it with your testers only: your host’s password protection, a simple shared secret checked in a Next.js proxy file (opens in a new tab) (called middleware in older versions), or a sign-in.
    • Set a spending limit in the Anthropic Console, so a leaked link can’t run up a large bill.
    • Add the key in your host’s environment settings, not in the repository.
    • Watch the logs after sharing. The JSON lines tell you who hit truncation, refusals or errors, and how long requests took.

    Common mistakes

    • Calling the API from the browser. Anything in client code, including a NEXT_PUBLIC_ variable, is public. Keep the key and the client on the server.
    • Handling only the happy path. Testers will hit truncation, refusals and errors. Design those states before sharing.
    • No prompt version in the logs. Without it, you can’t tell which prompt produced which feedback.
    • Publishing an open demo. Every request costs money. Restrict access and set a spending limit.
    • Demoing on invented inputs. Use the real inputs from your prompt-first test, so reactions reflect real use.

    Try it

    Exercise

    About 1 day

    Ship a one-day demo to five users

    1. Create the project, add the route and the component, and run it locally.
    2. Replace the sample with an input box or a list of real inputs from your prompt-first test.
    3. Force each final state from the table above and check what testers would see.
    4. Deploy with restricted access and a spending limit, share it with five people, and read the logs after they’ve used it.
    Show a hint

    If deployment eats your afternoon, run the demo locally and share your screen in a call with each tester. The point is real reactions, not hosting.

    Show a worked solution

    A finished day-one demo has:

    • The API key only in server environment variables, never in client code.
    • A route that validates input, streams, enforces a time limit and logs one JSON line per request with prompt version, outcome, tokens and timing.
    • A page with realistic inputs and every final state designed.
    • Restricted access and a spending limit.
    • Notes from five testers and a log you can summarize: for example, “28 drafts, 2 cut off, 0 refusals, median 3.1 s; testers edited most drafts for tone.”

    Check your understanding

    Question 1 of 4: Why must the API key never use the NEXT_PUBLIC_ prefix?

    Show answer

    B. Next.js embeds NEXT_PUBLIC_ values in browser JavaScript, so anyone could read the key

    Public variables are bundled into client code. Secrets must stay in server-only environment variables.

    Question 2 of 4: What does AbortSignal.any([request.signal, AbortSignal.timeout(...)]) achieve in the route?

    Show answer

    B. It cancels the model call when the user disconnects or when the time limit is reached

    One signal covers both cancellation sources, so a stuck or abandoned request does not keep running.

    Question 3 of 4: Why log the prompt version with every request?

    Show answer

    B. So feedback, outcomes and costs can be attributed to the prompt that produced them

    When you change the prompt, versioned logs let you compare before and after.

    Question 4 of 4: What should you do before sharing a deployed demo?

    Show answer

    B. Restrict access and set a spending limit

    Every click costs money; restricted access and a spending limit protect you from a leaked link.

    Key takeaways

    • A day-one demo gets the experience into real hands fast.
    • Keep the API key and client on the server; never use NEXT_PUBLIC_ for secrets.
    • Version the prompt, log one structured line per request, set a time limit, and handle every stop reason from day one.
    • Restrict access and cap spending before sharing, and demo on real inputs.

    Progress is saved in this browser and lights up the roadmap.