Agent-Customizable Web Apps: Let Users Reshape Your App With AI
What if your users could change your app — not by filing feature requests, but by telling an AI agent what they want and having it rewrite the interface on the spot?
I've been thinking about this pattern: you ship a core web app built the classic way (Next.js, React, a database, standard CRUD). Then you give each user the ability to run a code agent that modifies the app specifically for them. Not a theme switcher. Not a config panel. An actual agent that reads your components, understands the structure, and produces a personalized variant of the app.
Here's how I'd build it.
The Core Idea
Most SaaS apps ship one UI to everyone. Power users get the same layout as beginners. A sales team sees the same dashboard as engineering. Everyone adapts to the app — the app never adapts to them.
The alternative: give users an AI agent that can fork their view of the app and modify it. The core app remains stable and maintained. Each user gets a personal layer on top.
Core App (maintained by you)
↓
User's Agent Session
↓
Personalized App (maintained by the agent + user)
This isn't hypothetical. The primitives exist today — LLMs that can write and modify code, sandboxed execution environments, and component-based architectures that make partial overrides practical.
Architecture: How It Actually Works
Layer 1: The Core App
Build your app the standard way. Next.js App Router, TypeScript, a component library, database, auth — all the usual pieces. This is your stable base. It ships to every user and is the fallback when customizations break or don't exist.
The key architectural decision: every UI component must be independently overridable. This means:
// components/dashboard/stats-card.tsx
export default function StatsCard({ data }: StatsCardProps) {
return (
<div className="rounded-xl border p-6">
<h3 className="text-sm font-medium text-muted-foreground">{data.label}</h3>
<p className="text-2xl font-bold">{data.value}</p>
</div>
);
}Each component is a self-contained unit with a clear interface. The agent doesn't need to understand your entire app — just the component it's modifying and its props contract.
Layer 2: The Override System
This is the interesting part. You need a system that:
- Stores user-specific component overrides
- Resolves which version of a component to render (custom or default)
- Sandboxes custom code so it can't break the core app or access things it shouldn't
Here's the component resolution flow:
// lib/component-resolver.ts
import { getUserOverride } from "@/lib/overrides";
export async function resolveComponent(
componentPath: string,
userId: string
): Promise<React.ComponentType<any>> {
const override = await getUserOverride(userId, componentPath);
if (override && override.enabled) {
// Load the user's custom version from their sandbox
return loadSandboxedComponent(override.code);
}
// Fall back to the core component
return loadCoreComponent(componentPath);
}The override store is straightforward — a database table mapping (userId, componentPath) to the custom component code and metadata:
CREATE TABLE component_overrides (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
component_path TEXT NOT NULL, -- e.g., "dashboard/stats-card"
code TEXT NOT NULL, -- the custom component source
props_schema JSONB, -- expected props interface
enabled BOOLEAN DEFAULT true,
version INTEGER DEFAULT 1,
agent_session_id UUID, -- which agent session created this
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, component_path)
);Layer 3: The Agent
The agent is the user-facing piece. A user opens a panel, describes what they want, and the agent:
- Reads the current component source and props
- Plans the modification
- Writes a new version of the component
- Previews it in a sandbox
- Saves it as an override if the user approves
// lib/agent/customize.ts
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
export async function runCustomizationAgent(
userId: string,
componentPath: string,
userRequest: string
) {
// Get the current component source (custom or default)
const currentSource = await getComponentSource(componentPath, userId);
const propsInterface = await getPropsInterface(componentPath);
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 4096,
system: `You are a UI customization agent. You modify React components based on user requests.
Rules:
- Output ONLY the modified component code
- Preserve the props interface exactly: ${propsInterface}
- Use only Tailwind CSS for styling
- Do not add external dependencies
- Do not modify data fetching logic — only presentation
- Keep the component self-contained`,
messages: [
{
role: "user",
content: `Current component (${componentPath}):\n\n${currentSource}\n\nUser request: "${userRequest}"\n\nReturn the modified component.`,
},
],
});
const newCode = extractCode(response);
// Validate in sandbox before saving
const validation = await validateInSandbox(newCode, propsInterface);
if (validation.success) {
await saveOverride(userId, componentPath, newCode);
return { success: true, preview: validation.preview };
}
return { success: false, error: validation.error };
}Sandboxing: The Hard Part
Custom user code running in your app is a security minefield. You need isolation at multiple levels.
Option A: Server-Side Rendering in Isolated Containers
Run each user's custom components in a sandboxed environment (like a Web Worker, iframe, or even a lightweight container). The component renders to HTML server-side, and you inject the rendered output into the page.
// lib/sandbox/render.ts
export async function loadSandboxedComponent(code: string) {
// Transform the code with esbuild (fast, no node_modules access)
const bundled = await esbuild.transform(code, {
loader: "tsx",
format: "esm",
target: "es2022",
});
// Execute in a VM with restricted globals
const vm = new QuickJS();
const result = vm.evalCode(bundled.code, {
allowedGlobals: ["React", "console"],
timeout: 1000, // kill after 1 second
memoryLimit: 10 * 1024 * 1024, // 10MB max
});
return result;
}Option B: Client-Side Iframes (Simpler, More Limited)
Render custom components inside sandboxed iframes. Easier to implement, harder to make feel native.
<iframe
sandbox="allow-scripts"
srcdoc={renderedCustomComponent}
style="border: none; width: 100%; height: auto;"
/>Option C: AST-Level Restrictions (My Recommendation)
Instead of sandboxing execution, restrict what the agent can generate. Parse the output AST and reject anything dangerous:
// lib/sandbox/validate.ts
import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
export function validateComponentCode(code: string): ValidationResult {
const ast = parse(code, { sourceType: "module", plugins: ["tsx"] });
const violations: string[] = [];
traverse(ast, {
// No dynamic imports
ImportExpression(path) {
violations.push("Dynamic imports are not allowed");
},
// No eval or Function constructor
CallExpression(path) {
if (
path.node.callee.type === "Identifier" &&
["eval", "Function"].includes(path.node.callee.name)
) {
violations.push("eval/Function constructor not allowed");
}
},
// No accessing window, document, fetch directly
MemberExpression(path) {
if (
path.node.object.type === "Identifier" &&
["window", "document", "globalThis", "process"].includes(
path.node.object.name
)
) {
violations.push(`Access to ${path.node.object.name} not allowed`);
}
},
// No dangerouslySetInnerHTML
JSXAttribute(path) {
if (path.node.name.name === "dangerouslySetInnerHTML") {
violations.push("dangerouslySetInnerHTML not allowed");
}
},
});
return {
valid: violations.length === 0,
violations,
};
}In practice, you'd combine Option C with Option A — validate the code statically, then render it in an isolated context as a defense-in-depth measure.
Optimizing for Cost and Performance
Running an LLM every time a user asks for a change costs money and adds latency. Here's how to keep it practical.
1. Cache Aggressively
Component overrides are static after creation. Cache the compiled output at the edge:
// middleware.ts — resolve overrides at the edge
import { NextResponse } from "next/server";
export async function middleware(request: NextRequest) {
const userId = getUserFromSession(request);
const overrides = await redis.get(`overrides:${userId}`);
if (overrides) {
// Attach override manifest to the request
request.headers.set("x-component-overrides", overrides);
}
return NextResponse.next();
}2. Diff, Don't Regenerate
When a user requests a change to an already-customized component, send the agent only the diff context — not the full app. The agent should understand it's making an incremental change.
3. Pre-Compile Overrides
Don't compile custom components on every request. When an override is saved, compile it immediately and store the compiled output:
async function saveOverride(userId: string, path: string, code: string) {
const compiled = await esbuild.build({
stdin: { contents: code, loader: "tsx" },
bundle: false,
format: "esm",
minify: true,
});
await db.componentOverrides.upsert({
userId,
componentPath: path,
code, // original source (for future agent edits)
compiledCode: compiled, // ready to serve
version: sql`version + 1`,
});
// Invalidate edge cache
await redis.del(`overrides:${userId}`);
await redis.del(`compiled:${userId}:${path}`);
}4. Limit the Override Surface
Not every component should be customizable. Define an explicit allow-list:
// lib/overrides/config.ts
export const CUSTOMIZABLE_COMPONENTS = {
"dashboard/stats-card": {
description: "Individual stat card on the dashboard",
maxCodeSize: 5000, // bytes
},
"dashboard/chart-widget": {
description: "Chart display widget",
maxCodeSize: 10000,
},
"sidebar/nav-item": {
description: "Navigation menu item",
maxCodeSize: 3000,
},
// ... explicitly listed components only
} as const;Use Cases That Make This Worth Building
1. Internal Tools
Every team uses the same admin panel differently. Sales wants a pipeline view, support wants ticket queues front and center, engineering wants system metrics. Instead of building three dashboards, build one and let each team's agent customize it.
User prompt: "Move the revenue chart to the top, make it twice as wide, and add a red border when MRR drops below last month."
2. SaaS Dashboards
B2B SaaS customers always want "just one more thing" on their dashboard. Instead of a feature request backlog, give them an agent. They describe what they want, the agent modifies their view, done.
User prompt: "Replace the pie chart with a horizontal bar chart, group by region instead of product, and use our brand colors (#1a1a2e, #16213e, #0f3460)."
3. Accessibility Customization
Users with specific accessibility needs can have the agent restructure their interface — larger touch targets, different color schemes, simplified layouts — without waiting for your team to ship it.
User prompt: "Make all buttons at least 48px tall, increase font size to 18px base, and add visible focus rings to everything."
4. Workflow Optimization
A user who does the same 5-step process every day can have the agent combine those steps into a single custom view with auto-filled defaults.
User prompt: "Combine the client lookup, invoice creation, and email send into one screen. Pre-fill the email template with the client's name and invoice total."
The UX: How Users Interact With the Agent
The agent lives in a side panel — think of it like a browser DevTools but for non-technical users.
┌─────────────────────────────────────────────┐
│ Your App │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Stats │ │ Chart │ │ Activity │ │
│ │ Card │ │ Widget │ │ Feed │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ Table Component │ │
│ └──────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────┤
│ 🔧 Customize │
│ │
│ Click any component to customize it. │
│ │
│ > "Make the stats cards show sparkline │
│ graphs and color-code by performance" │
│ │
│ [Preview] [Apply] [Revert to Default] │
└─────────────────────────────────────────────┘
The flow:
- User clicks a component (or describes which one they mean)
- User types what they want in natural language
- Agent generates a preview — shown in-place with a subtle border
- User approves or iterates
- Override is saved and persists across sessions
A "Revert to Default" button is always available. Users should never feel locked into a customization they don't like.
What Could Go Wrong (And How to Handle It)
Breaking Changes in the Core App
When you update a core component's props interface, existing overrides may break. Handle this with version checking:
async function checkOverrideCompatibility(
userId: string,
componentPath: string
) {
const override = await getOverride(userId, componentPath);
const currentProps = await getPropsInterface(componentPath);
if (override.propsSchema !== currentProps) {
// Props changed — flag this override for re-generation
await markOverrideStale(userId, componentPath);
// Optionally: auto-migrate with the agent
await runCustomizationAgent(
userId,
componentPath,
`The component props have changed. Update the customization to match the new interface: ${currentProps}`
);
}
}Runaway Costs
Set per-user limits: number of customizations, agent calls per day, max component size. Start conservative and increase based on actual usage.
The "It Looked Better Before" Problem
Keep every version. Let users browse their customization history and roll back to any point:
// Every save creates a new version, old versions are never deleted
const history = await db.componentOverrides
.where({ userId, componentPath })
.orderBy("version", "desc")
.limit(20);The Bigger Picture
This pattern — a stable core with agent-customizable layers — isn't just for UI components. The same architecture works for:
- API response formats: users customize how data is shaped for their integrations
- Notification rules: agents write custom filtering logic per user
- Report templates: each user gets agent-generated report layouts
- Workflow automations: agents compose multi-step workflows from the app's building blocks
The underlying principle is the same: ship a well-built default, then let AI agents handle the long tail of personalization that no product team could ever staff for.
We're moving from "here's the app, adapt to it" to "here's the app, tell it what you need." The tools to build this exist today. The question is which products will ship it first.
Comments
No comments yet. Be the first to comment!