AirLink
Blog

Building your first AirLink addon

thavanish · Fri May 15 2026 00:00:00 GMT+0000 (Coordinated Universal Time) · 3 min read

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

  1. Restart the panel
  2. Go to Admin > Addons and enable "Server Notes"
  3. Visit /server-notes/admin to see the admin view
  4. 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 $queryRaw and $executeRaw for 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.

All posts