Video moderation creates operational bottlenecks, especially as your platform scales. In this guide, you’ll build an agentic video governance pipeline using Next.js 16 and Cloudinary. Instead of manually reviewing every upload, you’ll delegate moderation to AI agents that automatically:
- Watch every video upon upload.
- Flag and block inappropriate content.
- Tag content with descriptive metadata for searchability.
You’ll leverage the Next.js App Router for the frontend and Cloudinary’s Admin API as the backend intelligence. The system uses an architecture where the UI optimistically uploads content but pessimistically hides it until AI verification passes.
The pipeline operates autonomously:
-
Trigger: User uploads a video via the client component.
-
Agent 1 (Rekognition): Scans for moderation labels (safe vs. unsafe).
-
Agent 2 (Azure Video Indexer): Generates taxonomy and content tags.
-
Decision Engine: The UI re-fetches the status and decides whether to render the player (approved) or a warning shield (rejected).
-
Live Demo: video-governance-guide.vercel.app
-
Source Code: github.com/musebe/video-governance-guide
Start by initializing a Next.js 16 app with TypeScript and Tailwind CSS. Then, install the Cloudinary SDK and the Shadcn UI engine, which includes lucide-react for your icons.
Run these commands in your terminal:
# 1. Create the Next.js 16 App
npx create-next-app@latest video-governance --typescript --tailwind --eslint
# 2. Initialize Shadcn UI (Select 'New York', 'Slate', 'Yes' to CSS variables)
npx shadcn@latest init
# 3. Install Core Engines
npm install next-cloudinary lucide-react
Code language: CSS (css)
Create a .env.local file in your root. This is the security bridge between your app and Cloudinary’s AI.
# Public: For the Client-Side Upload Widget
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="<YOUR_CLOUD_NAME>"
NEXT_PUBLIC_CLOUDINARY_API_KEY="<YOUR_API_KEY>"
# Private: For Server Actions & Admin API (NEVER expose this)
CLOUDINARY_API_SECRET="<YOUR_API_SECRET>"
# Config: The Upload Preset we created
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET="video-governance-preset"
Code language: PHP (php)
This is the Core Engine #1. Instead of importing v2 everywhere, you’ll configure it once here. This prevents the common “Must supply api_key” error by ensuring the SDK is always initialized with your environment variables before any API call is made.
View the file on GitHub.
import { v2 as cloudinary } from "cloudinary";
// Global Configuration
cloudinary.config({
cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
api_key: process.env.NEXT_PUBLIC_CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
secure: true, // Force HTTPS
});
export default cloudinary;
Code language: JavaScript (javascript)
For the “Agentic” pipeline to work, you must enable two Add-ons in your Cloudinary Dashboard:
- AWS Rekognition Video Moderation for safety checks.
- Microsoft Azure Video Indexer for auto-tagging.
Security is paramount. You don’t expose your CLOUDINARY_API_SECRET to the client. Instead, you’ll use a Signed Upload pattern:
- Client requests a signature.
- Server validates and signs the request using the secret.
- Client uploads the video to Cloudinary with that signature.
This API route acts as the gatekeeper. It receives parameters from the client, signs them using the SDK’s utility function, and returns the signature.
View the file on GitHub.
import { v2 as cloudinary } from "cloudinary";
export async function POST(request: Request) {
const body = await request.json();
const { paramsToSign } = body;
// CORE ENGINE: Generate signature using the Secret
const signature = cloudinary.utils.api_sign_request(
paramsToSign,
process.env.CLOUDINARY_API_SECRET as string
);
return Response.json({ signature });
}
Code language: JavaScript (javascript)
You’ll use the CldUploadWidget to handle chunking and retries. The key configuration is pointing signatureEndpoint to our route above.
View the file on GitHub.
<CldUploadWidget
signatureEndpoint="/api/sign-cloudinary" // Points to our secure route
uploadPreset={process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET}
onSuccess={(result) => {
// Trigger Server Action to refresh the gallery immediately
startTransition(() => refreshGallery());
}}
>
{({ open }) => (
<Button onClick={() => open()}>
Upload Video
</Button>
)}
</CldUploadWidget>
Code language: HTML, XML (xml)
Using Next.js 16 Server Actions, you’ll instantly refresh the UI after an upload without a full page reload.
View the file on GitHub](https://github.com/musebe/video-governance-guide/blob/main/app/video-pipeline/actions.ts).
"use server";
import { revalidatePath } from "next/cache";
export async function refreshGallery() {
revalidatePath("/"); // Purges the cache for the home page
}
Code language: JavaScript (javascript)
To build your governance dashboard, you’ll use the Cloudinary Admin API (cloudinary.api.resources) and fetch the list of uploaded videos along with their moderation status and AI tags.
View the file on GitHub.
By default, Next.js caches data aggressively. However, moderation happens seconds after upload. If you cache the “Pending” state, the user will never see the “Approved” state.
Use unstable_noStore() to opt out of caching for this specific request, ensuring you’ll always get a fresh status from Cloudinary.
import cloudinary from "@/lib/cloudinary";
import { unstable_noStore as noStore } from "next/cache";
export async function getGovernedVideos() {
noStore(); // CORE ENGINE: Forces fresh data on every request
try {
const results = await cloudinary.api.resources({
type: "upload",
prefix: "governance/uploads", // Target specific folder
resource_type: "video",
moderation: true, // Fetch AI safety status
tags: true, // Fetch AI content tags
max_results: 50,
direction: "desc",
});
return results.resources;
} catch (error) {
console.error("Fetch Error:", error);
return [];
}
}
Code language: JavaScript (javascript)
To avoid any types and ensure our UI handles the data correctly, define a strict interface that matches the Admin API response structure.
export interface VideoAsset {
public_id: string;
created_at: string;
format: string;
bytes: number;
moderation_status?: "approved" | "rejected" | "pending";
tags?: string[];
}
Code language: PHP (php)
This is the Core Engine #2. You’ll implement a pessimistic security model where content is hidden by default until explicitly approved by the AI. This protects users from ever seeing harmful uploads while they’re being processed.
View the file on GitHub.
Inside your server component, map over the video list and switch entirely based on the moderation_status. Note that the <VideoPlayer> is only rendered if the status is strictly 'approved'.
{
videos.map((video) => {
const status = video.moderation_status || "pending";
const isSafe = status === "approved";
const isRejected = status === "rejected";
return (
<Card key={video.public_id}>
<div className="aspect-video relative">
{/* CASE 1: SAFE -> Render the Player */}
{isSafe && <VideoPlayer publicId={video.public_id} />}
{/* CASE 2: REJECTED -> Render the Shield */}
{isRejected && (
<div className="flex flex-col items-center justify-center bg-red-50 text-red-600 h-full">
<ShieldAlert className="h-10 w-10" />
<span className="font-bold">Content Blocked</span>
</div>
)}
{/* CASE 3: PENDING -> Render the Loader */}
{!isSafe && !isRejected && (
<div className="flex flex-col items-center justify-center h-full">
<Loader2 className="animate-spin text-blue-500" />
<span>AI Moderation in Progress...</span>
</div>
)}
</div>
</Card>
);
});
}
Code language: JavaScript (javascript)
You’ll also conditionally render the tags. If a video is rejected, make sure to hide the tags as well, since they might contain sensitive descriptions of the blocked content.
{
!isRejected && video.tags && (
<div className="flex gap-2">
{video.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
);
}
Code language: HTML, XML (xml)
The standard Cloudinary video player needs browser APIs (window, document) to attach event listeners and handle media streams. However, your Governance Dashboard is a Server Component (to keep API keys secure).
If you try to render the player directly, Next.js will throw a useState only works in Client Components error. Solve this with the Client Wrapper Pattern.
Create a dedicated component marked with "use client". This isolates the interactive logic from the server-side fetching logic.
View the file on GitHub.
"use client";
import { CldVideoPlayer } from "next-cloudinary";
import "next-cloudinary/dist/cld-video-player.css";
import { useState } from "react";
export default function VideoPlayer({ publicId }: { publicId: string }) {
const [error, setError] = useState(false);
// CORE ENGINE: Error Handling for HLS Delays
// If the HLS stream isn't ready, we catch the error and show a loader
if (error) {
return <div className="text-zinc-500">Processing Media...</div>;
}
return (
<CldVideoPlayer
width="1920"
height="1080"
src={publicId}
onError={() => setError(true)} // Catch transcoding errors
controls
/>
);
}
Code language: JavaScript (javascript)
When a video is first uploaded, Cloudinary generates the adaptive streaming formats (HLS/DASH) in the background. This can take a few seconds.
-
The problem. The player might try to load
.m3u8before it exists, causing a “Media Not Supported” error. -
The fix. Your
onErrorhandler catches this specific failure and swaps the player for a “Processing” state, preventing a broken UI experience.
You’ve built an Agentic Video Governance Pipeline. By combining Next.js 16 Server Actions with Cloudinary’s AI, you eliminated the need for manual moderation. Users get instant feedback, admins get peace of mind that harmful content is blocked automatically, and developers get a clean, type-safe codebase without having to manage complex media servers.
While this guide covers the loop of uploads and checks, a production-grade system could go further:
- Webhooks. Instead of polling or refreshing, use Cloudinary Webhooks to push updates to your database the moment AI processing finishes.
- Notifications. Connect the “Rejected” state to a Slack bot or Email service to alert a human admin for a final review.
You can find the complete source code for this project here: github.com/musebe/video-governance-guide
Ready to start building your own AI video moderator? Sign up for a free Cloudinary account today.