How addons work
Addons live in storage/addons/. When the panel starts, it loads every enabled addon and lets it register routes, UI bits, and migrations. Core files stay untouched.
Quick start
1. Create the addon directory
mkdir -p panel/storage/addons/my-addon/views
2. Create package.json
{
"name": "My Addon",
"version": "1.0.0",
"description": "What this addon does",
"author": "your-name",
"main": "index.ts",
"router": "/my-addon"
}
main— the entry point. Defaults toindex.ts.router— the base URL path for all routes in this addon.
3. Create the entry point
import { Router } from 'express';
import path from 'path';
export default function(router: Router, api: any) {
const { logger, prisma } = api;
router.get('/', async (req: any, res: any) => {
try {
const settings = await prisma.settings.findUnique({ where: { id: 1 } });
res.render(path.join(api.viewsPath, 'main.ejs'), {
user: req.session?.user,
req,
settings,
components: {
header: api.getComponentPath('views/components/header'),
template: api.getComponentPath('views/components/template'),
footer: api.getComponentPath('views/components/footer')
}
});
} catch (error) {
logger.error('addon error', error);
res.status(500).send('something broke');
}
});
}
4. Create a view
<%- include(components.header, { title: 'My Addon', user: user }) %>
<main class="h-screen m-auto">
<div class="flex h-screen">
<div class="w-60 h-full">
<%- include(components.template) %>
</div>
<div class="flex-1 p-6 overflow-y-auto pt-16">
<div class="px-8 mt-5">
<h1 class="text-base font-medium text-white">My Addon</h1>
</div>
</div>
</div>
</main>
<%- include(components.footer) %>
5. Enable it
Restart the panel, then go to Admin > Addons and enable your addon. Visit it at /my-addon.
Lifecycle
When the panel boots, it scans storage/addons/ for directories with a valid package.json. For each enabled addon:
- Migrations run (in order, tracked so they never repeat)
- The entry point's default function is called with an Express router and the API object
- Routes, sidebar items, and UI sections register themselves
When an addon is disabled, its routes stop matching but its data stays in the database.
renderView
For addons that need to render a view with full layout control:
const html = await api.renderView('main.ejs', {
user: req.session?.user,
settings,
customData: 'whatever'
});
res.send(html);
renderView resolves the view from your addon's views/ folder and wraps it with the panel layout.
Error handling
Wrap route handlers in try/catch. The panel logs unhandled errors but renders a generic 500 to the user.
router.get('/something', async (req: any, res: any) => {
try {
const data = await doWork();
res.render(path.join(api.viewsPath, 'page.ejs'), { data });
} catch (error) {
logger.error('my-addon error', error);
res.status(500).send('something broke');
}
});
Folder structure
my-addon/
├── package.json
├── index.ts
├── views/
│ ├── desktop/
│ │ └── main.ejs
│ ├── mobile/
│ │ └── main.ejs
│ └── main.ejs ← shared fallback
├── public/
│ ├── css/
│ ├── js/
│ └── img/
└── lib/
└── helpers.ts
Place templates in views/desktop/ and views/mobile/ for viewport-specific layouts. If only one version exists, put it in views/ as a shared fallback.
package.json reference
{
"name": "My Addon",
"version": "1.0.0",
"description": "What this addon does",
"author": "your-name",
"main": "index.ts",
"router": "/my-addon",
"enabled": true,
"migrations": [
{
"name": "my_addon_v1_create_items",
"sql": "CREATE TABLE IF NOT EXISTS MyAddonItems (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)"
}
]
}
main— entry point file. Defaults toindex.ts.router— base URL path for all routes.enabled— whether the addon loads by default. Default:true.migrations— SQL statements that run once when the addon is first enabled.
Addon API reference
The second argument passed to your default function gives you access to everything the panel exposes.
Core
logger.info / warn / error / debug— write to the panel logprisma— Prisma client connected to the panel databaseaddonPath— absolute path to your addon folderviewsPath— absolute path to your addon'sviews/folderdesktopViewsPath— absolute path toviews/desktop/mobileViewsPath— absolute path toviews/mobile/getComponentPath(path)— returns the absolute path to a panel layout componentrenderView(viewName, data?, isMobile?)— render a view template manually
User utilities
utils.isUserAdmin(userId)— checks whether the user is an adminutils.checkServerAccess(userId, serverId)— checks whether the user can access the serverutils.getServerById(serverId)— returns a server objectutils.getServerByUUID(uuid)— returns a server object by UUIDutils.getPrimaryPort(server)— returns the primary port for a server
UI registration
ui.addSidebarItem(item)— adds an entry to the main sidebarui.addServerMenuItem(item)— adds an item to the per-server sidebarui.addServerSection(section)— adds a section to the server page
Adding a sidebar item
api.ui.addSidebarItem({
id: 'my-addon',
name: 'My Addon',
icon: '<svg ...></svg>',
link: '/my-addon',
section: 'main',
order: 50
});
Database access
Addons have full access to the database through Prisma:
// Read from panel tables
const users = await api.prisma.users.findMany();
// Read from your own tables (created by migrations)
const results = await api.prisma.$queryRaw`SELECT * FROM MyAddonItems`;
// Write to your own tables
await api.prisma.$executeRaw`INSERT INTO MyAddonItems (name) VALUES (${name})`;
For tables defined by your addon's migrations (not in the Prisma schema), use $queryRaw and $executeRaw.
Installing an addon manually
cd /var/www/panel/storage/addons/
git clone https://github.com/you/your-addon.git your-addon
cd your-addon
pnpm install
pnpm run build
systemctl restart airlink-panel
Then go to Admin > Addons and enable it.