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:
| Field | Type | Description |
|---|---|---|
text | string (optional) | Assembled prompt or model completion to analyze |
checks | array (required) | One to four of: prompt_injection, pii_secrets, output_safety, agent_action |
redact | boolean (optional) | When true, PII check may return redacted text |
agent_action | object (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:
decision | Typical action |
|---|---|
allow | Proceed to next stage or deliver to client |
review | Queue for human review; show neutral fallback |
block | Safe 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
allowon both stages → content delivered
Use synthetic payloads from Prompt Injection Testing.
Checklist before production
-
IDENTICAPI_API_KEYserver-only; not inNEXT_PUBLIC_* - Full assembled prompt scanned (history + RAG), not just latest message
- Input checks:
prompt_injection,pii_secretswithredact: truewhere appropriate - Output checks:
output_safety,pii_secretson every user-visible completion - CSP headers on chat routes
-
request_idlogged 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
- How to Add Guardrails to an LLM Application
Add guardrails to an LLM application — input screening, output moderation, and agent action checks in a practical reques…
- How to Add Output Safety Checks in Next.js
Add AI output safety checks in Next.js App Router — server-side moderation, route handlers, streaming considerations, an…
- How to Add AI Security to a SaaS API Route
Add AI security to SaaS API routes — middleware control flow, input guards, model calls, output checks, and tenant-aware…