Frontend Development
Build plugin frontends with React, SDK hooks, and the NaaP design system.
Overview#
Plugin frontends are React applications that run inside the NaaP shell. They use the Plugin SDK to access shell services and follow the NaaP design system for consistent UI.
Project Setup#
Every plugin frontend has this structure:
| 1 | frontend/ |
| 2 | ├── src/ |
| 3 | │ ├── App.tsx # Root component |
| 4 | │ ├── mount.tsx # Shell mount/unmount |
| 5 | │ └── pages/ # Plugin pages |
| 6 | ├── vite.config.ts # UMD build config (createPluginConfig) |
| 7 | ├── tsconfig.json |
| 8 | └── package.json |
Using SDK Hooks#
Authentication#
| 1 | import { useAuth, useUserHasRole } from '@naap/plugin-sdk'; |
| 2 | |
| 3 | function MyComponent() { |
| 4 | const auth = useAuth(); |
| 5 | const isAdmin = useUserHasRole('admin'); |
| 6 | |
| 7 | if (!auth.isAuthenticated()) { |
| 8 | return <div>Please log in</div>; |
| 9 | } |
| 10 | |
| 11 | return ( |
| 12 | <div> |
| 13 | <p>Welcome, {auth.getUser()?.displayName}</p> |
| 14 | {isAdmin && <AdminPanel />} |
| 15 | </div> |
| 16 | ); |
| 17 | } |
API Calls#
| 1 | import { usePluginApi } from '@naap/plugin-sdk'; |
| 2 | |
| 3 | function DataList() { |
| 4 | const api = usePluginApi(); |
| 5 | const [data, setData] = useState([]); |
| 6 | |
| 7 | useEffect(() => { |
| 8 | api.get('/items').then(setData); |
| 9 | }, []); |
| 10 | |
| 11 | return ( |
| 12 | <ul> |
| 13 | {data.map(item => ( |
| 14 | <li key={item.id}>{item.name}</li> |
| 15 | ))} |
| 16 | </ul> |
| 17 | ); |
| 18 | } |
Deployment-Aware Backend URLs#
NaaP plugins run in two environments with very different network topologies:
| Environment | Backend access | Example URL |
|---|---|---|
| Local dev | Each plugin backend runs on its own port | http://localhost:4006/api/v1/community/posts |
| Vercel / production | All traffic goes through the Next.js API proxy on the same origin | /api/v1/community/posts |
The SDK provides two functions for URL resolution. Use the one that matches your call pattern:
Pattern A: API Prefix + Relative Suffix (recommended for most plugins)#
Use getPluginBackendUrl() when your plugin calls endpoints under a single API prefix:
| 1 | import { getPluginBackendUrl } from '@naap/plugin-sdk'; |
| 2 | |
| 3 | const API_BASE = getPluginBackendUrl('community', { |
| 4 | apiPath: '/api/v1/community', |
| 5 | }); |
| 6 | // Dev: http://localhost:4006/api/v1/community |
| 7 | // Prod: /api/v1/community |
| 8 | |
| 9 | fetch(`${API_BASE}/posts`); // /api/v1/community/posts |
| 10 | fetch(`${API_BASE}/leaderboard`); // /api/v1/community/leaderboard |
Pattern B: Origin + Full Path (for cross-service calls)#
Use getServiceOrigin() when your plugin calls multiple different API paths (e.g., registry, teams, installations):
| 1 | import { getServiceOrigin } from '@naap/plugin-sdk'; |
| 2 | |
| 3 | const origin = getServiceOrigin('base'); |
| 4 | // Dev: http://localhost:4000 |
| 5 | // Prod: '' (empty string = same-origin) |
| 6 | |
| 7 | fetch(`${origin}/api/v1/registry/packages`); |
| 8 | fetch(`${origin}/api/v1/teams/${teamId}/plugins`); |
| 9 | fetch(`${origin}/api/v1/installations`); |
What NOT to Do#
| 1 | // WRONG — breaks on Vercel (port 4006 does not exist) |
| 2 | const API_BASE = 'http://localhost:4006/api/v1/community'; |
| 3 | |
| 4 | // WRONG — appends port to production hostname |
| 5 | const API_BASE = `${window.location.protocol}//${window.location.hostname}:4006`; |
| 6 | |
| 7 | // WRONG — creates /api/v1/base/api/v1/registry/packages (doubled path!) |
| 8 | const base = getPluginBackendUrl('base'); // returns '/api/v1/base' in prod |
| 9 | fetch(`${base}/api/v1/registry/packages`); // DOUBLED! |
Quick Reference#
| I want to... | Use this function |
|---|---|
Call /api/v1/community/posts, /api/v1/community/tags | getPluginBackendUrl('community', { apiPath: '/api/v1/community' }) then append /posts, /tags |
Call /api/v1/registry/packages, /api/v1/teams/... | getServiceOrigin('base') then append the full path |
| Build a React hook with automatic URL resolution | usePluginApi({ pluginName: 'my-plugin', apiPath: '/api/v1/my-plugin' }) |
Run ./bin/validate-plugin-urls.sh before committing to catch common URL mistakes.
Pre-Built API Clients#
The SDK also exports two higher-level clients that use getServiceOrigin('base') internally.
You do not need to call getServiceOrigin() yourself when using these:
| 1 | import { createShellApiClient, createIntegrationClient } from '@naap/plugin-sdk'; |
| 2 | |
| 3 | // For core shell endpoints (auth, teams, installations, etc.) |
| 4 | const shell = createShellApiClient(); |
| 5 | await shell.get('/api/v1/registry/packages'); |
| 6 | |
| 7 | // For plugin-to-plugin integration calls |
| 8 | const integration = createIntegrationClient(); |
| 9 | await integration.get('/api/v1/community/posts'); |
How Production Detection Works#
The SDK uses isProductionHost() for unified environment detection:
- In the browser: checks
window.location.hostnameagainst known production hosts - On the server (SSR): checks environment variables (
VERCEL,NEXT_PUBLIC_VERCEL_URL, etc.)
You should never implement your own environment detection — always rely on the SDK functions.
Catch-All Proxy & API Route Parity#
On Vercel / production, the Next.js catch-all proxy (/api/v1/[plugin]/[...path]) returns
501 Not Implemented for any route that does not have a dedicated Next.js route handler.
This means every plugin API endpoint must have a corresponding file at:
apps/web-next/src/app/api/v1/{plugin-name}/...There are currently 46+ dedicated route handlers covering all plugin endpoints. If you add a new API endpoint to your plugin backend, you must also add a matching Next.js route handler — otherwise the endpoint will not work in production.
Event Communication#
| 1 | import { useEvents } from '@naap/plugin-sdk'; |
| 2 | |
| 3 | function StatusWidget() { |
| 4 | const events = useEvents(); |
| 5 | const [status, setStatus] = useState('idle'); |
| 6 | |
| 7 | useEffect(() => { |
| 8 | // Listen for events from other plugins |
| 9 | const unsub = events.on('gateway:status-changed', (data) => { |
| 10 | setStatus(data.status); |
| 11 | }); |
| 12 | return unsub; |
| 13 | }, [events]); |
| 14 | |
| 15 | // Emit events for other plugins |
| 16 | const handleAction = () => { |
| 17 | events.emit('task-tracker:task-completed', { taskId: '123' }); |
| 18 | }; |
| 19 | |
| 20 | return <button onClick={handleAction}>Complete ({status})</button>; |
| 21 | } |
Notifications#
| 1 | import { useNotify } from '@naap/plugin-sdk'; |
| 2 | |
| 3 | function SaveButton() { |
| 4 | const notify = useNotify(); |
| 5 | |
| 6 | const handleSave = async () => { |
| 7 | try { |
| 8 | await saveData(); |
| 9 | notify.success('Saved successfully!'); |
| 10 | } catch (error) { |
| 11 | notify.error('Failed to save. Please try again.'); |
| 12 | } |
| 13 | }; |
| 14 | |
| 15 | return <button onClick={handleSave}>Save</button>; |
| 16 | } |
Theme-Aware Components#
| 1 | import { useThemeService } from '@naap/plugin-sdk'; |
| 2 | |
| 3 | function ThemedCard({ children }) { |
| 4 | const theme = useThemeService(); |
| 5 | |
| 6 | return ( |
| 7 | <div className={`rounded-xl border p-4 ${ |
| 8 | theme.mode === 'dark' |
| 9 | ? 'bg-gray-800 border-gray-700' |
| 10 | : 'bg-white border-gray-200' |
| 11 | }`}> |
| 12 | {children} |
| 13 | </div> |
| 14 | ); |
| 15 | } |
Styling#
Tailwind CSS#
Plugins should use Tailwind CSS classes for styling. The shell provides CSS variables for all theme colors:
| 1 | /* Available CSS variables */ |
| 2 | --background |
| 3 | --foreground |
| 4 | --primary |
| 5 | --primary-foreground |
| 6 | --muted |
| 7 | --muted-foreground |
| 8 | --border |
| 9 | --card |
| 10 | --card-foreground |
Using the Theme#
| 1 | // Use semantic color classes |
| 2 | <div className="bg-background text-foreground"> |
| 3 | <h1 className="text-primary">Title</h1> |
| 4 | <p className="text-muted-foreground">Description</p> |
| 5 | <div className="bg-card border border-border rounded-xl"> |
| 6 | Card content |
| 7 | </div> |
| 8 | </div> |
Routing Within a Plugin#
Plugins can have multiple pages using their route prefix:
| 1 | // plugin.json routes: ["/task-tracker", "/task-tracker/*"] |
| 2 | |
| 3 | function App() { |
| 4 | const path = window.location.pathname; |
| 5 | |
| 6 | if (path === '/task-tracker/settings') { |
| 7 | return <SettingsPage />; |
| 8 | } |
| 9 | |
| 10 | return <TaskListPage />; |
| 11 | } |
Shared UI Components#
Use components from @naap/ui for consistent design:
| 1 | import { Card, Badge, DataTable, SearchInput } from '@naap/ui'; |
| 2 | |
| 3 | function PluginPage() { |
| 4 | return ( |
| 5 | <Card> |
| 6 | <SearchInput placeholder="Search tasks..." /> |
| 7 | <DataTable columns={columns} data={data} /> |
| 8 | <Badge variant="success">Active</Badge> |
| 9 | </Card> |
| 10 | ); |
| 11 | } |
Build Configuration#
Shared Vite Config#
All plugins use the shared build configuration from @naap/plugin-build. Your vite.config.ts should look like this:
| 1 | import { createPluginConfig } from '@naap/plugin-build/vite'; |
| 2 | |
| 3 | export default createPluginConfig({ |
| 4 | name: 'my-plugin', |
| 5 | displayName: 'My Plugin', |
| 6 | globalName: 'NaapPluginMyPlugin', |
| 7 | defaultCategory: 'platform', |
| 8 | }); |
This shared config handles:
- UMD bundle output, React externals, rollup globals
- PostCSS / Tailwind CSS — configured inline; do not add a
postcss.config.jsto your plugin - Manifest generation and bundle validation
Do NOT Add postcss.config.js#
PostCSS (Tailwind CSS, Autoprefixer) is configured centrally in the shared Vite config. Adding a postcss.config.js in your plugin will break Vercel builds because PostCSS tries to require('tailwindcss') from your plugin directory, where it cannot find the hoisted dependency.
| 1 | plugins/my-plugin/frontend/ |
| 2 | ├── tailwind.config.js ← YES — keep this (per-plugin theme/content) |
| 3 | ├── postcss.config.js ← NO — do NOT create this file |
| 4 | └── vite.config.ts ← Uses createPluginConfig (includes PostCSS) |
Tailwind Config#
Each plugin keeps its own tailwind.config.js for content paths and theme extensions. The shared Vite config passes { config: './tailwind.config.js' } to Tailwind, so your config is automatically picked up.
Hot Reload#
During development, naap-plugin dev enables hot module replacement. Changes to your React components update instantly in the browser without losing state.