Frontend
The backend can store and serve shouts. Now let's show them. On this page we mount a widget on the home page, then add a panel page where admins manage shouts. This is the Svelte half — the part visitors and admins actually see and click.
Full reference: Frontend Development.
This half is hot — no rebuilds
Unlike Kotlin, the UI hot-reloads. Start the watcher once and leave it running the whole time:
bun run devEvery change below shows up on a browser refresh (F5), as long as Development Mode is on and your clone is under the install's plugins/ folder.
The entry point: src/main.js
Everything starts in main.js, which the boilerplate already has. It exports one default class extending PanoPlugin. Pano runs its onLoad() twice — once in the theme (the public site) and once in the panel (the admin dashboard). You tell them apart with pano.isPanel:
export default class ShoutboxUiPlugin extends PanoPlugin {
onLoad() {
const { pano } = this;
if (pano.isPanel) {
// panel registrations go here
} else {
// theme registrations go here
}
}
}Two rules that matter everywhere below:
- Wrap every component in
viewComponent(() => import('./File.svelte')). This is not optional — it hands Pano a recipe for loading your file with the page's own copy of Svelte. pluginIdmust exactly match the id from the backend (pano-plugin-shoutbox). Translations and hooks are keyed on it.
Never add svelte to package.json
Your bundle does not ship Svelte, svelte-i18n, or @panomc/sdk — the host provides them so the whole page shares one Svelte instance. Adding your own copy breaks hydration. If your build starts failing right after a bun add, check for a stray svelte entry and remove it. See Architecture for why.
Step 1 — mount the widget on the home page
The theme exposes named hooks — spots where addons can inject a component. To put Shoutbox at the top of the home page, register a component for the page:home:top hook, in the else (theme) branch:
pano.ui.hook.register({
name: 'page:home:top',
component: viewComponent(() => import('./theme/ShoutboxWidget.svelte')),
});Check
Open the site's home page. You should see the widget's container at the very top (inspect it with devtools). It'll be empty until the next step — that's expected. If it's missing entirely, check the browser console and confirm you registered in the else branch with the right pluginId.
Step 2 — give the widget its data with load()
A widget needs data, and it needs that data in the first server response so visitors and search engines see the shouts immediately. A hook component does this by exporting a load(event) from its module script — the <script module> block. Pano runs load() while the page is prepared and hands what you return to the component as props:
<!-- src/theme/ShoutboxWidget.svelte -->
<script module>
import ApiUtil from '@panomc/sdk/utils/api';
export async function load(event) {
const res = await ApiUtil.get({ path: '/api/shoutbox/list', request: event });
return { shouts: res.shouts ?? [] };
}
</script>
<script>
export let shouts = [];
</script>
<div class="shoutbox">
{#each shouts as shout}
<p class="shout">{shout.message}</p>
{/each}
</div>This calls the public endpoint you built on the Backend page. Two rules for load():
- Always pass
request: eventso the server-side call carries the visitor's session. Forget it and the fetch runs logged out during SSR — data goes missing only on a hard refresh, which is a confusing bug to chase. load()runs on the server and the client, so keep it side-effect-free: only fetch and return data.
How ApiUtil reports errors
ApiUtil never throws on API errors — a failed call resolves to an object with error set. Check res.error before using the response; that's why the load() above falls back to res.shouts ?? [].
Check
Refresh the home page — the widget now shows one <p class="shout"> per shout (if your backend has any; post one via the panel page below). To prove the data is in the first response, hard-refresh (Ctrl/Cmd+Shift+R) and use View source — the shouts should already be in the HTML, not blank.
Step 3 — a panel page for managing shouts
Now the admin side. When your addon needs a page of its own — a management screen at /shoutbox — register it as a page and add a link to the panel sidebar, both in the if (pano.isPanel) branch:
pano.ui.page.register({
path: '/shoutbox',
component: viewComponent(() => import('./panel/ShoutboxPage.svelte')),
permission: 'pano.plugin.pano-plugin-shoutbox.manage.shoutbox',
});
pano.ui.nav.site.editNavLinks(async (links) => {
if (!links.some((l) => l.href === '/shoutbox')) {
const i = links.findIndex((l) => l.href === '/posts');
const link = {
href: '/shoutbox',
icon: 'fas fa-bullhorn',
text: `plugins.${pluginId}.nav.shoutbox`,
startsWith: true,
permission: 'pano.plugin.pano-plugin-shoutbox.manage.shoutbox',
};
i >= 0 ? links.splice(i + 1, 0, link) : links.push(link);
}
return links;
});A few things worth knowing:
nav.siteis the panel's main sidebar. The extrasiteword is not a typo — other nav areas exist.- That
permissionstring is a hand-typed copy of the node your KotlinManageShoutboxPermissionclass derives. There's no shared constant — if you rename the Kotlin class, change both together, or the gate silently stops matching. textis a translation key, not a label. Until you add it (next page), the sidebar shows the raw keyplugins.pano-plugin-shoutbox.nav.shoutbox. That's expected here.- Guard against duplicates.
editNavLinksre-runs on every page load in the long-lived server, so checklinks.some(...)before adding — and always return the array.
Inside ShoutboxPage.svelte you build the actual management UI: list the shouts, a form that calls ApiUtil.post({ path: '/api/panel/shoutbox', body: { message } }) to add one, and a delete button. To confirm an action, show a toast with showToast from @panomc/sdk/toasts. Full examples are in Panel UI.
Check
Reload the panel. A bullhorn icon appears in the sidebar just under Posts, labelled with the raw key (it turns into real text once you add the locale key next page). Click it to open your page at /shoutbox. If permission isn't met, the page 404s and the link is hidden.
A cheaper alternative: a settings section
If you don't need a whole page, you can instead add a component to your addon's detail page (the panel:plugin-detail:content:<pluginId> hook) — the cheapest way to give an addon a settings screen. Most built-in addons do exactly this; see Panel UI.
Beware fake APIs
If an AI assistant or an old tutorial hands you a call that isn't on the Frontend API Reference, it doesn't exist. Common fakes: ApiUtil.get('/api/...') with a plain string (every call takes an options object), a @panomc/sdk/components/panel component library (there is none), and onContextUpdate (no host ever calls it — delete it if scaffolding added it). The full list is at the bottom of the Frontend reference.
Where we are
Shoutbox now has a home-page widget with server-rendered data and a panel page with its own nav link. But its text is still hard-coded English. Let's fix that.
Next: Translations →