Developer Guides
·IdenticAPI

How to Add AI Guardrails to a Next.js App

Add AI guardrails to Next.js App Router apps — server-side Unified Guard calls, input/output checks, and keeping API keys off the client.

Add AI guardrails to a Next.js App Router application by calling IdenticAPI Unified Guard from Route Handlers or Server Actions — never from Client Components. Run prompt_injection, pii_secrets, and output_safety at the correct pipeline stages, route on aggregated decision values (block > review > allow), and keep idapi_test_ or production keys in server-only environment variables.

This guide targets Next.js 14+ with the App Router. For output-only patterns, see Output Safety in Next.js. For guardrail concepts, see What Are AI Guardrails? and Add Guardrails to an LLM Application.

Architecture overview

Client Component (chat UI)
    │
    ▼ POST /api/chat
Route Handler (server, "use server" boundary)
    ├── assemble prompt (system + history + user message)
    ├── POST /api/v1/guard  [prompt_injection, pii_secrets]  ← input stage
    ├── call LLM provider (OpenAI, Anthropic, etc.)
    ├── POST /api/v1/guard  [output_safety, pii_secrets]     ← output stage
    ├── route decision → deliver / review / block
    └── return JSON or stream approved text

API keys live in process.env.IDENTICAPI_API_KEY and are read only in app/api/**, lib/** server modules, or files marked "use server". Never import guard helpers into Client Components.

Unified Guard request schema

IdenticAPI validates requests against the Unified Guard schema:

FieldTypeDescription
textstring (optional)Assembled prompt or model completion to analyze
checksarray (required)One to four of: prompt_injection, pii_secrets, output_safety, agent_action
redactboolean (optional)When true, PII check may return redacted text
agent_actionobject (optional)Required when agent_action is in checks

For input and output text checks in a chat app, text is required. Maximum text length is 32,000 characters per request.

Environment setup

.env.local (never commit):

IDENTICAPI_API_KEY=idapi_test_your_synthetic_key_here
OPENAI_API_KEY=your_provider_key_here

Add .env.local to .gitignore. Use idapi_test_ keys in development and staging; rotate to idapi_live_ for production.

Server-only guard client

Create lib/unified-guard.ts:

// lib/unified-guard.ts — import only from server modules
const BASE_URL = "https://www.identicapi.com";

export type GuardCheck =
  | "prompt_injection"
  | "pii_secrets"
  | "output_safety"
  | "agent_action";

export type UnifiedGuardRequest = {
  text?: string;
  checks: GuardCheck[];
  redact?: boolean;
  agent_action?: {
    tool_name: string;
    action: string;
    arguments?: Record<string, unknown>;
    context?: string;
    policy_id?: string;
  };
};

export type GuardCheckResult = {
  check: GuardCheck;
  verdict: "allow" | "review" | "block";
  risk: string;
  findings: Array<{ category: string; reason: string }>;
  reasons: string[];
};

export type UnifiedGuardResponse = {
  request_id: string;
  api: "unified-guard";
  decision: "allow" | "review" | "block";
  checks: GuardCheckResult[];
  usage_units: number;
  processing_time_ms: number;
  detector_version: string;
};

export async function runUnifiedGuard(
  body: UnifiedGuardRequest
): Promise<UnifiedGuardResponse> {
  const apiKey = process.env.IDENTICAPI_API_KEY;
  if (!apiKey) throw new Error("IDENTICAPI_API_KEY is not configured");

  const res = await fetch(`${BASE_URL}/api/v1/guard`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify(body),
    cache: "no-store"
  });

  if (!res.ok) {
    throw new Error(`Unified Guard failed: ${res.status}`);
  }

  return res.json() as Promise<UnifiedGuardResponse>;
}

export const FALLBACK_MESSAGE =
  "I can't process that request right now. Please rephrase or contact support.";

Full schema reference: Unified Guard docs.

Route Handler with input and output guards

app/api/chat/route.ts:

import { NextResponse } from "next/server";
import { runUnifiedGuard, FALLBACK_MESSAGE } from "@/lib/unified-guard";

export const runtime = "nodejs";

type ChatBody = { message: string };

function assemblePrompt(userMessage: string): string {
  // Include system prompt, chat history, and RAG chunks in production
  return `User: ${userMessage}`;
}

async function callLlm(prompt: string): Promise<string> {
  // Replace with your provider client
  return `Assistant reply for: ${prompt.slice(0, 80)}`;
}

export async function POST(req: Request) {
  let body: ChatBody;
  try {
    body = await req.json();
  } catch {
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }

  const userMessage = body.message?.trim();
  if (!userMessage) {
    return NextResponse.json({ error: "message is required" }, { status: 400 });
  }

  const assembledPrompt = assemblePrompt(userMessage);

  // Input stage: injection + PII before provider call
  let inputGuard;
  try {
    inputGuard = await runUnifiedGuard({
      text: assembledPrompt,
      checks: ["prompt_injection", "pii_secrets"],
      redact: true
    });
  } catch {
    return NextResponse.json(
      { role: "assistant", content: FALLBACK_MESSAGE, decision: "unknown" },
      { status: 503 }
    );
  }

  if (inputGuard.decision === "block") {
    return NextResponse.json({
      role: "assistant",
      content: FALLBACK_MESSAGE,
      decision: inputGuard.decision,
      request_id: inputGuard.request_id
    });
  }

  if (inputGuard.decision === "review") {
  await queueForReview({ stage: "input", userMessage, inputGuard });
    return NextResponse.json({
      role: "assistant",
      content: FALLBACK_MESSAGE,
      decision: inputGuard.decision,
      request_id: inputGuard.request_id
    });
  }

  let assistantText: string;
  try {
    assistantText = await callLlm(assembledPrompt);
  } catch {
    return NextResponse.json({ error: "LLM unavailable" }, { status: 502 });
  }

  // Output stage: safety + PII echo before client delivery
  let outputGuard;
  try {
    outputGuard = await runUnifiedGuard({
      text: assistantText,
      checks: ["output_safety", "pii_secrets"]
    });
  } catch {
    return NextResponse.json(
      { role: "assistant", content: FALLBACK_MESSAGE, decision: "unknown" },
      { status: 503 }
    );
  }

  if (outputGuard.decision !== "allow") {
    if (outputGuard.decision === "review") {
      await queueForReview({ stage: "output", userMessage, assistantText, outputGuard });
    }
    return NextResponse.json({
      role: "assistant",
      content: FALLBACK_MESSAGE,
      decision: outputGuard.decision,
      request_id: outputGuard.request_id
    });
  }

  return NextResponse.json({
    role: "assistant",
    content: assistantText,
    decision: outputGuard.decision,
    request_id: outputGuard.request_id
  });
}

async function queueForReview(_payload: unknown) {
  // Persist to your review store
}

This implements guardrails before and after the LLM with one integration pattern.

Client Component (no API keys)

"use client";

import { useState } from "react";

export function ChatPanel() {
  const [messages, setMessages] = useState<{ role: string; content: string }[]>([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);

  async function send() {
    const text = input.trim();
    if (!text || loading) return;
    setInput("");
    setMessages((m) => [...m, { role: "user", content: text }]);
    setLoading(true);
    try {
      const res = await fetch("/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: text })
      });
      const data = await res.json();
      setMessages((m) => [...m, { role: "assistant", content: data.content }]);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div>
      {messages.map((msg, i) => (
        <p key={i}>
          <strong>{msg.role}:</strong> {msg.content}
        </p>
      ))}
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button onClick={send} disabled={loading}>Send</button>
    </div>
  );
}

Render assistant content as escaped text. For Markdown or HTML, sanitize server-side — Safe AI-Generated HTML.

Server Actions alternative

For form-based flows, call runUnifiedGuard inside a Server Action ("use server" file) with the same input/output split. Do not pass guard responses to Client Components beyond what users need to see.

Streaming considerations

If you use streaming completions, buffer tokens server-side, run output guard on the assembled message, then stream approved text. Do not forward raw LLM tokens to the browser before output checks complete. See Moderating AI Chatbot Responses.

Decision routing

Unified Guard aggregates per-check verdicts: block > review > allow. Map to product behavior:

decisionTypical action
allowProceed to next stage or deliver to client
reviewQueue for human review; show neutral fallback
blockSafe fallback; do not forward flagged content

See Block vs Review for AI Output.

Fail-closed on transport errors

When POST /api/v1/guard times out or returns 5xx, return FALLBACK_MESSAGE rather than unguarded LLM text. Document this explicitly — Fail Open vs Fail Closed.

Testing

Add integration tests (Vitest + fetch mock) asserting:

  • Input block → LLM never called; client receives fallback only
  • Output block → assistant text not delivered
  • Guard API failure → fail-closed fallback
  • allow on both stages → content delivered

Use synthetic payloads from Prompt Injection Testing.

Checklist before production

  • IDENTICAPI_API_KEY server-only; not in NEXT_PUBLIC_*
  • Full assembled prompt scanned (history + RAG), not just latest message
  • Input checks: prompt_injection, pii_secrets with redact: true where appropriate
  • Output checks: output_safety, pii_secrets on every user-visible completion
  • CSP headers on chat routes
  • request_id logged for incident correlation

Cross-reference LLM Security Middleware for broader placement patterns and Combine AI Security Guardrails for multi-check request examples.

Summary

Next.js App Router guardrails belong in Route Handlers and Server Actions: call POST /api/v1/guard with stage-appropriate checks arrays, keep API keys off the client, route on decision, and treat transport failures as fail-closed. Explore Unified Guard · Read the docs

Frequently asked questions

Where should AI guardrails run in a Next.js App Router app?

In Route Handlers or Server Actions on the server — after you assemble the prompt and after the LLM returns, before JSON or streams reach Client Components. Never call IdenticAPI from the browser or expose API keys in client bundles.

What Unified Guard checks should a Next.js chat app use?

Input stage: prompt_injection and pii_secrets with optional redact true on the assembled prompt. Output stage: output_safety and pii_secrets on the full completion before delivery.

What is the IdenticAPI endpoint for Unified Guard?

POST https://www.identicapi.com/api/v1/guard with Authorization Bearer API key and JSON body containing checks (required), optional text, redact, and agent_action. See /docs/unified-guard for the full schema.

How do I handle streaming chat with guardrails in Next.js?

Buffer completion text server-side, run output guard on the assembled message, then deliver approved content. Do not forward raw LLM tokens to the client before output checks complete.

What should happen when Unified Guard returns block or errors?

Return a pre-approved fallback message, log request_id, and do not deliver flagged or unmoderated LLM text. Most production apps fail closed when the guard API itself is unavailable.

Related reading