Building your first AirLink addon
This tutorial walks through building a simple addon that adds a server notes feature. Users can write notes on their servers, and admins can see all notes across all servers.
What we are building
An addon with:
- A database table for notes
- A route that shows notes on the server page
- An admin page that lists all notes
- A sidebar entry for the admin view
Step 1: the directory
mkdir -p panel/storage/addons/server-notes/views
cd panel/storage/addons/server-notes
Step 2: package.json
{
"name": "Server Notes",
"version": "1.0.0",
"description": "Add notes to servers",
"author": "you",
"main": "index.ts",
"router": "/server-notes",
"migrations": [
{
"name": "server_notes_v1_create_table",
"sql": "CREATE TABLE IF NOT EXISTS ServerNotes (id INTEGER PRIMARY KEY AUTOINCREMENT, serverUuid TEXT NOT NULL, userId INTEGER NOT NULL, note TEXT NOT NULL, createdAt TEXT NOT NULL DEFAULT (datetime('now')))"
}
]
}
The migration creates a table with a server UUID, user ID, note text, and timestamp.
Step 3: the entry point
import { Router } from "express";
import path from "path";
export default function (router: Router, api: any) {
const { logger, prisma } = api;
// Admin page — list all notes
router.get("/admin", async (req: any, res: any) => {
if (
!req.session?.user ||
!(await api.utils.isUserAdmin(req.session.user.id))
) {
return res.redirect("/login");
}
try {
const notes = await prisma.$queryRaw`
SELECT sn.*, u.username, s.name as serverName
FROM ServerNotes sn
LEFT JOIN Users u ON sn.userId = u.id
LEFT JOIN Servers s ON sn.serverUuid = s.uuid
ORDER BY sn.createdAt DESC
`;
res.render(path.join(api.viewsPath, "admin.ejs"), {
user: req.session.user,
req,
notes,
components: {
header: api.getComponentPath("views/components/header"),
template: api.getComponentPath("views/components/template"),
footer: api.getComponentPath("views/components/footer"),
},
});
} catch (error) {
logger.error("server-notes admin error", error);
res.status(500).send("something broke");
}
});
// API — add a note
router.post("/api/add", async (req: any, res: any) => {
if (!req.session?.user)
return res.status(401).json({ error: "not logged in" });
const { serverUuid, note } = req.body;
if (!serverUuid || !note)
return res.status(400).json({ error: "missing fields" });
try {
await prisma.$executeRaw`
INSERT INTO ServerNotes (serverUuid, userId, note)
VALUES (${serverUuid}, ${req.session.user.id}, ${note})
`;
res.json({ success: true });
} catch (error) {
logger.error("server-notes add error", error);
res.status(500).json({ error: "failed to add note" });
}
});
// Add sidebar item
api.ui.addSidebarItem({
id: "server-notes",
name: "All Notes",
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
link: "/server-notes/admin",
section: "admin",
order: 60,
});
}
Step 4: the admin view
Create views/admin.ejs:
<%- include(components.header, { title: 'Server Notes', 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">All Server Notes</h1>
<p class="mt-1 text-sm text-neutral-500">
Notes added by users on their servers
</p>
</div>
<div class="px-8 mt-5">
<% if (notes.length === 0) { %>
<p class="text-neutral-500">No notes yet</p>
<% } else { %>
<div class="space-y-3">
<% notes.forEach(function(note) { %>
<div class="rounded-xl bg-neutral-900 p-4">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-white"
><%= note.serverName || 'Unknown' %></span
>
<span class="text-xs text-neutral-500"
><%= note.createdAt %></span
>
</div>
<p class="mt-2 text-sm text-neutral-400"><%= note.note %></p>
<p class="mt-1 text-xs text-neutral-600">
by <%= note.username || 'unknown' %>
</p>
</div>
<% }); %>
</div>
<% } %>
</div>
</div>
</div>
</main>
<%- include(components.footer) %>
Step 5: enable and test
- Restart the panel
- Go to Admin > Addons and enable “Server Notes”
- Visit
/server-notes/adminto see the admin view - Use the API endpoint to add notes programmatically
What you learned
- How to define a migration in
package.json - How to create routes with Express
- How to use
$queryRawand$executeRawfor addon tables - How to register a sidebar item
- How to render views with the panel’s layout components
From here you can add server-specific note views, subuser permissions, or integrate with the console to add notes from the terminal.