Next.js Chat App

Build a privacy-first chat app with Next.js and Noirdoc.

Reference application

This is a complete example application demonstrating Noirdoc integration in a Next.js app. For basic provider integration, see the Integration guides.

Overview

Build a privacy-preserving chat application with Next.js and Noirdoc, based on patterns from the noirdoc-chat reference implementation. The approach uses server-side API routes with raw fetch to stream responses from the Noirdoc proxy, including a custom SSE event that exposes pseudonymization details.

Environment variables

Store your Noirdoc configuration in .env.local:

NOIRDOC_PROXY_URL=https://api.noirdoc.de
NOIRDOC_PROXY_KEY=px-your-noirdoc-key

Keep these server-side only — never expose the proxy key to the browser.

Server-side API route

Create a Route Handler that forwards chat messages through the Noirdoc proxy with streaming enabled.

// app/api/chat/route.ts
import { NextRequest } from "next/server";

export async function POST(req: NextRequest) {
  const { messages, model } = await req.json();

  const response = await fetch(
    `${process.env.NOIRDOC_PROXY_URL}/v1/messages`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-api-key": process.env.NOIRDOC_PROXY_KEY!,
        "anthropic-version": "2023-06-01",
      },
      body: JSON.stringify({
        model: model || "claude-sonnet-4-6",
        max_tokens: 4096,
        stream: true,
        messages,
      }),
    }
  );

  if (!response.ok) {
    const error = await response.text();
    return new Response(error, { status: response.status });
  }

  // Forward the SSE stream to the client
  return new Response(response.body, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

This route uses raw fetch instead of an SDK, which gives full control over streaming and allows access to custom SSE events injected by Noirdoc.

Client-side stream parsing

Parse the SSE stream on the client to extract text deltas and pseudonymization events.

// lib/stream.ts
export interface PseudonymizationResult {
  original: string;
  pseudonymized: string;
  entities: Array<{
    entity_type: string;
    text: string;
    start: number;
    end: number;
    score: number;
  }>;
  mapping: Record<string, string>;
}

export interface StreamCallbacks {
  onText: (text: string) => void;
  onPseudonymization?: (result: PseudonymizationResult) => void;
  onDone: () => void;
}

export async function parseSSEStream(
  response: Response,
  callbacks: StreamCallbacks
) {
  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop() || "";

    let eventType = "";
    for (const line of lines) {
      if (line.startsWith("event: ")) {
        eventType = line.slice(7).trim();
      } else if (line.startsWith("data: ")) {
        const data = line.slice(6);

        if (data === "[DONE]") {
          callbacks.onDone();
          return;
        }

        try {
          const parsed = JSON.parse(data);

          if (eventType === "pseudonymization") {
            callbacks.onPseudonymization?.(parsed);
          } else if (parsed.type === "content_block_delta") {
            callbacks.onText(parsed.delta?.text || "");
          }
        } catch {
          // Skip non-JSON lines
        }

        eventType = "";
      }
    }
  }

  callbacks.onDone();
}

Chat component

Wire the stream parser into a React component.

// components/Chat.tsx
"use client";

import { useState, useCallback } from "react";
import { parseSSEStream, PseudonymizationResult } from "@/lib/stream";

interface Message {
  role: "user" | "assistant";
  content: string;
}

export default function Chat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [isStreaming, setIsStreaming] = useState(false);
  const [pseudonymization, setPseudonymization] =
    useState<PseudonymizationResult | null>(null);

  const sendMessage = useCallback(async () => {
    if (!input.trim() || isStreaming) return;

    const userMessage: Message = { role: "user", content: input };
    const updatedMessages = [...messages, userMessage];
    setMessages([...updatedMessages, { role: "assistant", content: "" }]);
    setInput("");
    setIsStreaming(true);

    const response = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ messages: updatedMessages }),
    });

    let assistantContent = "";

    await parseSSEStream(response, {
      onText: (text) => {
        assistantContent += text;
        setMessages([
          ...updatedMessages,
          { role: "assistant", content: assistantContent },
        ]);
      },
      onPseudonymization: (result) => {
        setPseudonymization(result);
      },
      onDone: () => {
        setIsStreaming(false);
      },
    });
  }, [input, messages, isStreaming]);

  return (
    <div>
      {messages.map((msg, i) => (
        <div key={i} className={msg.role === "user" ? "text-right" : ""}>
          <p>{msg.content}</p>
        </div>
      ))}

      {pseudonymization && (
        <details>
          <summary>
            PII detected: {pseudonymization.entities.length} entities
          </summary>
          <ul>
            {pseudonymization.entities.map((e, i) => (
              <li key={i}>
                {e.entity_type}: &quot;{e.text}&quot; (score: {e.score.toFixed(2)})
              </li>
            ))}
          </ul>
        </details>
      )}

      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyDown={(e) => e.key === "Enter" && sendMessage()}
        placeholder="Type a message..."
      />
      <button onClick={sendMessage} disabled={isStreaming}>
        Send
      </button>
    </div>
  );
}

The pseudonymization SSE event

During streaming, Noirdoc injects a custom pseudonymization SSE event before the main content events. This event contains the full pseudonymization result for the request:

event: pseudonymization
data: {"original":"...","pseudonymized":"...","entities":[...],"mapping":{"<<PERSON_1>>":"Max Mustermann"}}

The mapping field is a dictionary from pseudonym tokens to original values. You can use this to show users which personal data was detected and protected, build audit trails, or display inline annotations in the chat UI.

Using with OpenAI instead of Anthropic

To use OpenAI models, change the API route to target the Chat Completions endpoint:

const response = await fetch(
  `${process.env.NOIRDOC_PROXY_URL}/v1/chat/completions`,
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.NOIRDOC_PROXY_KEY}`,
    },
    body: JSON.stringify({
      model: "gpt-5.4-mini",
      stream: true,
      messages,
    }),
  }
);

The client-side parsing logic needs to handle the OpenAI SSE format, where text deltas appear in choices[0].delta.content instead of delta.text.

Standalone pseudonymization

You can also call the pseudonymize endpoint directly from your server for use cases that do not involve an LLM:

const result = await fetch(
  `${process.env.NOIRDOC_PROXY_URL}/v1/pseudonymize`,
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.NOIRDOC_PROXY_KEY}`,
    },
    body: JSON.stringify({
      text: "Max Mustermann, email max@example.com",
      language: "de",
    }),
  }
);

const { pseudonymized, entities, mapping } = await result.json();

This is useful for previewing PII detection results, building data sanitization pipelines, or displaying entity highlights in the UI before sending a message.