Building UI Application Plugins
This guide walks you through developing, building, and deploying a UI application plugin for Vertesia.
UI applications are React-based plugins that extend the Vertesia Studio interface with custom user experiences focused on specific use cases and business processes.
Vertesia provides a collection of reusable React components (via @vertesia/ui) to facilitate UI development, along with built-in authentication handling, allowing you to focus on your application's business logic rather than infrastructure and tooling concerns.
LLM Reference
For AI assistants, comprehensive component documentation is available at:
- @vertesia/ui components: unpkg.com/@vertesia/ui/llms.txt
- Plugin development: docs.vertesia.io/llms.txt
Prerequisites
- An application manifest created and installed in your Vertesia project
- Node.js 22+ and pnpm (or npm)
Creating Your Plugin
Initialize the Project
Use the @vertesia/create-plugin package to scaffold your plugin:
npm init @vertesia/plugin
You will be prompted for:
- Package manager: npm or pnpm
- Plugin name: Must match the
namefrom your app manifest (kebab-case) - Plugin version: Semantic version (e.g., 1.0.0)
- Description: Optional description
- Isolation strategy:
shadow(recommended) orcss
Project Structure
my-plugin/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── vercel.json # Deployment config
├── manifest.json # App manifest for registration
├── src/
│ ├── plugin.tsx # Plugin entry point (minimal)
│ ├── app.tsx # Main app component
│ ├── routes.tsx # Route definitions
│ ├── index.css # Tailwind CSS entry
│ ├── main.tsx # Standalone app entry (for dev)
│ ├── assets.ts # Asset URL resolution
│ ├── env.ts # Dev environment config
│ ├── context/ # React contexts
│ ├── pages/ # Page components
│ ├── components/ # Shared components
│ └── hooks/ # Custom hooks
└── dist/
└── lib/
├── plugin.js # Built plugin bundle
└── plugin.css # Built CSS
Key Files
plugin.tsx (Entry Point)
Keep this minimal - just export the component with PortalContainerProvider:
import { PortalContainerProvider } from '@vertesia/ui/core';
import { App } from './app';
export default function MyPlugin({ slot }: { slot: string }) {
if (slot === 'page') {
return (
);
}
console.warn('No component found for slot', slot);
return null;
}
app.tsx (Main App)
Import CSS here, set up providers and router:
import './index.css';
import { NestedRouterProvider } from '@vertesia/ui/router';
import { MyContextProvider } from './context/MyContext';
import { routes } from './routes';
export function App() {
return (
);
}
routes.tsx
Define routes as an array:
import { HomePage } from './pages/HomePage';
import { DetailPage } from './pages/DetailPage';
export const routes = [
{ path: '/', Component: HomePage },
{ path: '/items/:id', Component: DetailPage },
{ path: '*', Component: () => Not found },
];
package.json
{
"name": "@vertesia/plugin-my-plugin",
"version": "0.1.0",
"type": "module",
"plugin": {
"title": "My Plugin",
"publisher": "vertesia",
"external": false,
"status": "beta"
},
"scripts": {
"dev": "vite dev",
"build": "vite build --mode lib",
"build:app": "vite build --mode app"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"dependencies": {
"@vertesia/common": "^0.80.0",
"@vertesia/ui": "^0.80.0",
"dayjs": "^1.11.10",
"lucide-react": "^0.511.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-basic-ssl": "^2.1.0",
"@vitejs/plugin-react": "^5.1.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.0"
}
}
vite.config.ts
import tailwindcss from '@tailwindcss/vite';
import basicSsl from '@vitejs/plugin-basic-ssl';
import react from '@vitejs/plugin-react';
import { defineConfig, type ConfigEnv, type UserConfig } from 'vite';
// Dependencies provided by host app - don't bundle these
const EXTERNALS = [
'react',
'react-dom',
'react/jsx-runtime',
'react-dom/client',
'@vertesia/ui',
/^@vertesia\/ui\/.*/,
'@vertesia/common',
'@vertesia/client',
'lucide-react',
'dayjs',
/^dayjs\/.*/,
];
export default defineConfig((env) => {
if (env.mode === 'lib') {
return defineLibConfig(env);
}
return defineAppConfig();
});
function defineLibConfig({ command }: ConfigEnv): UserConfig {
if (command !== 'build') {
throw new Error("Use 'vite build --mode lib' for library builds");
}
return {
plugins: [tailwindcss(), react()],
build: {
outDir: 'dist/lib',
cssCodeSplit: false,
lib: {
entry: './src/plugin.tsx',
formats: ['es'],
fileName: 'plugin',
},
minify: true,
sourcemap: true,
rollupOptions: {
external: EXTERNALS,
output: { assetFileNames: 'plugin.[ext]' },
}
}
};
}
function defineAppConfig(): UserConfig {
return {
plugins: [tailwindcss(), react(), basicSsl()],
server: {
proxy: {
'/__/auth': {
target: 'https://dengenlabs.firebaseapp.com',
changeOrigin: true,
}
}
},
resolve: { dedupe: ['react', 'react-dom'] }
};
}
Using Vertesia UI Components
Import from @vertesia/ui/*:
Important API Note: Input passes value directly to onChange, while Textarea uses standard React events:
// Input - simplified API (value directly)
setName(value)} />
// Textarea - standard React (event object)
// Core components
import { Button, Card, Input, VModal, VTabs } from '@vertesia/ui/core';
// Hooks
import { useFetch, useToast } from '@vertesia/ui/core';
// Router
import { useNavigate, useParams, NavLink, NestedRouterProvider } from '@vertesia/ui/router';
// Session/Auth
import { useUserSession } from '@vertesia/ui/session';
// Layout
import { FullHeightLayout } from '@vertesia/ui/layout';
// Features
import { GenericPageNavHeader } from '@vertesia/ui/features';
Using the Vertesia Client
import { useUserSession } from '@vertesia/ui/session';
function MyComponent() {
const { client } = useUserSession();
// Collections
const collections = await client.store.collections.list();
// DataStores
const dataStores = await client.data.list();
// Create content object with file
await client.store.objects.create(
{ content: file, name: file.name },
{ collection_id: collectionId }
);
// Launch agent
await client.runs.create({
interaction: 'app:my_interaction',
data: { /* payload */ },
tags: ['my-tag'],
});
}
Data Fetching Pattern
Use useFetch hook:
import { useFetch } from '@vertesia/ui/core';
import { useUserSession } from '@vertesia/ui/session';
function MyPage() {
const { client } = useUserSession();
const { data, isLoading, error, refetch } = useFetch(
() => client.store.collections.list(),
[] // dependencies
);
if (isLoading) return ;
if (error) return {error.message} ;
return {data.map(item => ...)};
}
Styling
Use Tailwind CSS with Vertesia's semantic classes:
// Semantic colors
// Cards
Title
Content
Common Patterns
Modal Dialogs
Use VModal (note: use isolation: "css" in manifest for inputs to work):
setOpen(false)} size="md">
Title
setValue(e.target.value)} />
Tabbed Interface
},
{ name: 'tab2', label: 'Tab 2', content: },
]}>
Page Header with Breadcrumbs
Home,
Current Page,
]}
/>
Development
Start the development server:
pnpm dev
The app runs at https://localhost:5173 with hot module replacement.
Building
# Build plugin bundle
pnpm build
# Output: dist/lib/plugin.js, dist/lib/plugin.css
Deployment
Deploying to Vercel
Create a vercel.json in your plugin directory:
{
"installCommand": "cd ../.. && pnpm install",
"buildCommand": "pnpm build",
"outputDirectory": "dist/lib",
"rewrites": [
{ "source": "/plugin", "destination": "/plugin.js" }
],
"headers": [
{
"source": "/(.*)",
"headers": [{ "key": "Access-Control-Allow-Origin", "value": "*" }]
}
]
}
Deploy to Vercel:
vercel --prod
Important: Disable deployment protection in Vercel project settings for the plugin to be publicly accessible.
App Manifest
Create a manifest.json:
{
"name": "my-plugin",
"title": "My Plugin",
"description": "Description of what the plugin does",
"publisher": "Your Organization",
"visibility": "private",
"status": "beta",
"icon": "<svg>...</svg>",
"color": "blue",
"ui": {
"src": "https://your-deployment-url.vercel.app/plugin.js",
"isolation": "css"
},
"tool_collections": [],
"interactions": null,
"settings_schema": null
}
Update the App Manifest
After deployment, update your app manifest's ui.src to point to your deployed URL.
Via the Vertesia CLI:
vertesia apps update my-app --manifest '{
"name": "my-app",
"title": "My App",
"ui": {
"src": "https://your-app.vercel.app/plugin.js",
"isolation": "css"
}
}'
Or through the UI in Settings > Available Apps.
Isolation Strategies
Shadow DOM (isolation: "shadow")
- Full isolation from host styles
- Recommended for most plugins
- Set
CONFIG__inlineCss = falsein vite.config.ts
CSS-only (isolation: "css")
- Lighter weight, injects Tailwind utilities into host
- Required if using Radix UI portals (modals with inputs)
- Set
CONFIG__inlineCss = truein vite.config.ts - May have style conflicts with host
Register the App
- Deploy to Vercel (or other static hosting)
- Disable deployment protection for public access
- Register manifest via API or UI (
Settings > Available Apps) - Install app in your project
