Architecting a Next.js SaaS with AI Agents
Lessons from building CBlindspot: orchestrating AI agents, handling long-running tasks beyond Vercel's timeout, and designing a workflow builder from scratch.
What is CBlindspot?
CBlindspot is a LegalTech SaaS I built for IP professionals — lawyers, brand managers, and companies that need to monitor trademark registrations across multiple databases. The problem is surprisingly painful: trademark filings happen across dozens of jurisdictions (EUIPO, USPTO, INPI, WIPO…), and staying on top of new registrations that might conflict with your own marks is a full-time job if you do it manually.
The product lets clients configure monitoring rules — specific keywords, classes, territories, similarity thresholds — and get automated alerts when something worth investigating pops up. From there they can take action directly in the platform: flag a conflict, initiate an opposition procedure, or generate a report for their legal team.
Simple idea on paper. The implementation turned out to be everything but.
Why Next.js?
I picked Next.js App Router for a few reasons that made sense for this specific product, not just because it's the trendy choice.
The app has genuinely complex layouts: a dashboard with nested navigation, detail pages that share context with list views, and a workflow builder that lives inside the main app shell. App Router's nested layouts handle all of this cleanly — one layout component per segment, shared state managed at the right level.
Server components matter here because trademark data pages are heavy. A typical monitoring report might include 50–200 trademark records with images, status timelines, and similarity scores. Fetching all of that on the server and streaming it down means users see content faster, and I don't have to think about loading states for data that will never change on the client anyway.
API routes handle webhooks from Trigger.dev (more on that shortly), Stripe events, and OAuth callbacks. Vercel makes deployment effortless — push to main, it deploys. Preview deployments per PR are genuinely useful when you're building something with a lot of moving parts.
High-level architecture
The AI Agent Architecture
The core of CBlindspot is a set of AI agents that handle the actual trademark intelligence work. I use the Claude API with tool use to give each agent a specific capability set. There's a search agent that knows how to query trademark databases, a comparison agent that evaluates visual and phonetic similarity, and a report agent that synthesizes findings into human-readable summaries.
The orchestration pattern looks roughly like this: a client submits a monitoring job, an orchestrator agent decides which sub-agents to call and in what order, and each sub-agent uses its tools to do the actual work. Results bubble back up to the orchestrator, which decides whether it has enough information or needs to dig deeper.
async function runMonitoringAgent(job: MonitoringJob) {
const tools = [
searchTrademarkTool,
compareSimilarityTool,
fetchDocumentTool,
flagConflictTool,
];
const messages: Message[] = [
{
role: 'user',
content: buildJobPrompt(job),
},
];
// Agentic loop
while (true) {
const response = await anthropic.messages.create({
model: 'claude-opus-4-5',
max_tokens: 4096,
tools,
messages,
});
if (response.stop_reason === 'end_turn') break;
if (response.stop_reason === 'tool_use') {
const toolResults = await executeTools(
response.content.filter(b => b.type === 'tool_use')
);
messages.push(
{ role: 'assistant', content: response.content },
{ role: 'user', content: toolResults },
);
}
}
return extractFindings(messages);
}Each tool is a typed function that queries either our own database, external trademark APIs (EUIPO TMview, USPTO TSDR), or runs similarity algorithms. The Claude model decides which tools to call and interprets the results — I don't hardcode the logic of "check EUIPO first, then USPTO." The agent figures out the right sequence for each specific job.
This flexibility is genuinely powerful. A monitoring job for a French luxury brand looks completely different from one for a US tech startup, and the agent adapts without me writing separate code paths for each case.
The Vercel Timeout Problem
Here's where things got painful. A full monitoring job — search multiple databases, run similarity comparisons, generate a report — takes anywhere from 2 to 5 minutes. Vercel serverless functions have a 60-second timeout on the Pro plan. That's a hard wall.
What I tried first: streaming
Streaming responses was a partial fix. I could stream intermediate results back to the client so the UI didn't look frozen, but the underlying serverless function still hit the timeout for long jobs. The connection would drop mid-stream. Not great.
What I tried second: Edge functions
Edge functions have no timeout, technically — but they're limited in what they can do. No Node.js APIs, strict memory limits, and the Anthropic SDK doesn't work cleanly in the Edge runtime. Dead end for this use case.
The actual solution: Trigger.dev
Trigger.dev is a background jobs platform with a great Next.js integration. The pattern is: API route triggers a job and returns immediately with a job ID, the job runs in Trigger.dev's infrastructure (no timeout), the client polls for status or subscribes via webhook.
import { task } from '@trigger.dev/sdk/v3';
export const monitoringJob = task({
id: 'trademark-monitoring',
// No timeout — runs as long as needed
run: async (payload: MonitoringJobPayload) => {
const { jobId, clientId, config } = payload;
await updateJobStatus(jobId, 'running');
try {
const findings = await runMonitoringAgent({
searchTerms: config.keywords,
territories: config.territories,
classes: config.niceClasses,
similarityThreshold: config.threshold,
});
await saveFindings(jobId, findings);
await updateJobStatus(jobId, 'complete');
await notifyClient(clientId, jobId);
} catch (err) {
await updateJobStatus(jobId, 'failed', err.message);
}
},
});// app/api/monitoring/trigger/route.ts
export async function POST(req: Request) {
const body = await req.json();
const { clientId, config } = body;
const job = await db.monitoringJob.create({
data: { clientId, config, status: 'queued' },
});
// Returns immediately — job runs in background
await monitoringJob.trigger({
jobId: job.id,
clientId,
config,
});
return Response.json({ jobId: job.id });
}The client-side UI polls /api/monitoring/status/[jobId] every few seconds and updates a progress indicator. It's not the sexiest UX but it's honest and reliable. I considered WebSockets but the polling approach is simpler to reason about and works fine for a job that completes in a few minutes.
The Workflow Builder
One of the more ambitious features in CBlindspot is a visual workflow builder that lets IP professionals design custom monitoring pipelines without writing code. Think drag-and-drop nodes: "Search EUIPO" → "Filter by similarity > 70%" → "Send email alert" → "Log to case file."
I built it with React Flow, which handles all the graph rendering, connection logic, and drag behavior. Each node type maps to a specific agent capability or action. The workflow is serialized to JSON, stored in MongoDB, and interpreted at runtime by the orchestrator.
Example workflow (visual representation)
The hardest part wasn't the UI — React Flow handles that well. It was building the runtime interpreter that reads the workflow JSON and executes the right agents in the right order, respecting conditional branches and passing data between nodes.
I ended up treating each workflow execution as a state machine. Every node has a status (pending, running, complete, skipped, failed), and the interpreter walks the graph, evaluating conditions and invoking agents. All of this runs inside a Trigger.dev task, so no timeout issues.
Database Design: MongoDB + Prisma
I went with MongoDB instead of Postgres, which feels like an unusual choice in 2026 but made sense here. Trademark data is structurally inconsistent across jurisdictions. A EUIPO record looks nothing like a USPTO record — different fields, different hierarchies, different date formats. A document database lets me store records as-is without forcing everything into a rigid schema.
Prisma's MongoDB adapter gives me a typed ORM on top of that flexibility. I define a base schema for the fields I always have (id, source, status, dates, owner name) and use Prisma's Json type for the source-specific fields. It's a pragmatic compromise that works well.
model Trademark {
id String @id @default(auto()) @map("_id") @db.ObjectId
source String // "euipo" | "uspto" | "inpi" | "wipo"
sourceId String
name String
status String
filingDate DateTime
ownerId String?
classes Int[]
// Source-specific fields stored as JSON
rawData Json
// Computed by our AI agent
embedding Float[]?
@@unique([source, sourceId])
@@map("trademarks")
}One thing to watch: Prisma + MongoDB can generate some gnarly N+1 query patterns if you're not careful. I added query logging in development and caught several places where a list view was doing one query per trademark record. A few strategicincludeandselectfixes solved it.
Authentication: Better Auth
CBlindspot is multi-tenant — a law firm might have 15 users, each with different access levels. Some can configure workflows, others can only view reports. I used Better Auth instead of NextAuth because the multi-tenancy support is first-class and the API is cleaner.
The setup includes OAuth (Google, Microsoft — because IP professionals live in Microsoft 365), magic link email auth, and role-based access control. Better Auth handles all of this with minimal configuration. I define roles as an enum and annotate API routes with a simple middleware check.
One thing I appreciated: Better Auth stores sessions in your own database, not in a third-party service. For a LegalTech product with data-conscious clients, that matters.
Lessons Learned
Start simpler than you think you need to
My first architecture had a message queue, a separate worker service, and a Redis cache layer. None of it was necessary at the beginning. I ripped all of it out and replaced it with Trigger.dev, which does the same job with a fraction of the operational overhead. Complexity should be earned, not assumed.
AI agents need guardrails
An agent loop with no limits will call tools until it hits your credit limit. I learned this the hard way during a testing session that burned through $40 of Claude API credits in 20 minutes. Every agent now has a max tool call budget, a timeout, and retry logic with exponential backoff. These are not optional additions — they're core to any production agent system.
Vercel is great until you need long-running tasks
For a typical Next.js marketing site or even a standard CRUD app, Vercel is nearly frictionless. The moment you need to run something that takes more than a minute, you need a different tool. That's not a knock on Vercel — it's just important to understand the model early. Plan for it from day one if your product involves any kind of async processing.
Prisma + MongoDB: good DX, watch for N+1
The developer experience is genuinely good — typed queries, autocomplete, migrations (of a sort). But the ORM abstraction can hide inefficient query patterns. Turn on query logging in development from the start, not as an afterthought. And be deliberate about yourincludedepth — fetching nested relations you don't need is a silent performance killer.
CBlindspot is still actively being developed. The core monitoring and agent pipeline is in production, the workflow builder is in beta with a small group of IP firms near Toulouse. If you're building something similar — a Next.js SaaS with AI agents, complex background jobs, or multi-tenant auth — feel free to reach out. Always happy to compare notes.
— Andy