---
url: https://panomc.com/docs/addon/theme-ui.md
---
# Theme UI

**What this page gives you:** everything for the visitor-facing side of your addon — mount a component on the theme, give it server-rendered data, and call your backend. By the end the Shoutbox widget shows recent shouts on the home page.

The site side is made of **view files** under `src/theme/views/` (see [Views](/addon/frontend/#views-one-file-per-page-or-widget)); they need no code in `main.js`. Only the registrations in the [Advanced](#advanced-—-skip-on-first-read) part go in the **`else` (theme) branch** of `onLoad()` in [`main.js`](/addon/frontend/).

## Mount a widget on the home page

The theme exposes named **hooks** — spots where addons can inject a component (the full list of hook names is in the [Frontend API Reference](/addon/api-reference/); this tutorial uses `page:home:top`). To show the Shoutbox on the home page, name the `page:home:top` hook in the view file's `export const view`:

```svelte
<!-- src/theme/views/ShoutboxWidget.svelte -->
<script module>
  export const view = { hook: 'page:home:top' };
</script>

<div class="shoutbox"></div>
```

That is the whole registration. On the site, `pano.ui.hook.register({ name, component })` does not work: an item with `component` and no `view` is dropped with an error.

::: tip Check
With the dev servers running, open the site's home page. You should see the `<div class="shoutbox">` container at the very top of the page (inspect it with your browser devtools). If you have not added `load()` yet — the next step — it will be empty; that is expected. If you don't see it at all, check the browser console for errors and confirm that the file is under `src/theme/views/` and the hook name is spelled right.
:::

### Give the widget server-rendered data with `load()`

A good widget needs data, and it needs that data to be there in the **first server response** — not fetched later in the browser — so visitors and search engines see the shouts immediately. Pano does this with **SSR** (server-side rendering — the server builds the finished HTML before sending it, instead of sending an empty page that fetches its data afterwards).

A hook component can export a `load(event)` function from its **module script** — the `<script module>` block. That block runs once when the file is first loaded, before any instance of the component exists, which is why `load()` lives there and your normal per-instance component code lives in the plain `<script>` below it. The theme runs `load()` while the page is being prepared (on the server during SSR, and again on the client when navigating between pages) and hands whatever you return to the component as props. In Shoutbox, `load()` calls our public endpoint:

```svelte
<!-- src/theme/views/ShoutboxWidget.svelte -->
<script module>
  import { api } from '@panomc/sdk/plugin-api';

  export const view = { hook: 'page:home:top' };

  export async function load(event) {
    const res = await api.get({ path: '/shouts', request: event });
    return { shouts: res.items ?? [] };
  }
</script>

<script>
  let { shouts = [] } = $props();
</script>

<div class="shoutbox">
  {#each shouts as shout}
    <p class="shout">{shout.message}</p>
  {/each}
</div>
```

`event` is the incoming page request — it carries the visitor's cookies and session. You mostly just forward it to `api` (as `request: event`) so the API knows who is asking.

The object you return becomes the component's props — here `shouts` arrives ready to render. (The host calls this props flow `hookProps`; you'll meet that name in the API reference and in error messages.)

::: warning `load()` runs on the server *and* the client
The same `load()` executes during SSR and again on client-side navigation, so keep it **safe to run twice**: it should only fetch and return data. Don't change global variables, don't write anything, and don't modify the objects you were handed — because the same function runs once on the server and again in the browser. (The one-word name for "safe to run twice with no side effects" is *idempotent*.) Always pass `request: event` to `api` (next section) so the server-side call carries the visitor's session.
:::

::: tip Check
Refresh the home page. The `<div class="shoutbox">` should now contain one `<p class="shout">` per shout (assuming your backend has any). To confirm the data really is in the *first* response, do a hard refresh (Ctrl/Cmd+Shift+R) and use "View source" — you should see the shouts already present in the HTML, not blank.
:::

::: tip Hide the widget when it has nothing to show
If `load()` returns `{ hookOptions: { invisible: true } }`, the host renders nothing for that hook. The Announcement addon uses this to disappear when there is nothing to display.
:::

## Calling your API

Calls to your own plugin's endpoints go through `api`, a client that already knows your plugin id. Paths are the relative ones your Kotlin endpoint declared (`/shouts`), and `api.panel` reaches the endpoints of a `PanelApi`. Calls to core endpoints use `ApiUtil` with paths relative to `/api/v1`. Each method takes a single options object:

```js
import { api } from '@panomc/sdk/plugin-api';
import ApiUtil from '@panomc/sdk/utils/api';   // core endpoints only

// In a load() — pass request so the server-side call has the session:
const res = await api.get({ path: '/shouts', request: event });

// In a browser event handler — body is your JSON payload:
await api.panel.post({ path: '/shouts', body: { message } });
await api.panel.delete({ path: `/shouts/${id}` });
await api.panel.put({ path: '/config', body: config });
```

The rule: **inside `load()`, always pass `request: event`** so the fetch runs with the visitor's session during SSR. In a click handler running in the browser you can omit it.

::: warning If you forget `request: event`
The call still works in the browser, but during SSR it runs **logged out**. The symptom is confusing: data is missing or you get permission errors only on a hard refresh, while everything looks fine after clicking around. If you ever see that, check your `load()` calls first.
:::

::: tip How `ApiUtil` reports errors
`api` and `ApiUtil` never throw on API errors: a failed call resolves to `{ error: { code, message?, details?, fields? } }`. Always check `res.error` (and branch on `res.error.code`) before using the response. Lists come back as `{ items, page }`; see [Pages](/addon/api-reference/#pages).
:::

## Advanced — skip on first read

Everything below is for cases you will not hit on your first addon. Come back when you need it.

### Fetch shared data once with `pano.ui.app.onLoad`

`pano.ui.app.onLoad(callback)` registers a function the **theme** runs on every page request, before the page renders. Its callback receives `(data, event)`, where `data` is a shared bag of page data and `event` is the request (the same kind you pass to `api`). Use it when one fetch needs to feed several registrations at once.

It is the alternative to a per-component `load()`: give the hook view `skipLoad: true` and fetch its data from a single `pano.ui.app.onLoad(async (data, event) => { ... })` instead. The Announcement addon does this; the FAQ and Pages addons use `app.onLoad` to register pages from what they fetch.

### Dynamic pages and cleanup

Sometimes the pages you register aren't known at build time — they come from your backend (custom URLs, redirects, and the like). Register them from inside `pano.ui.app.onLoad`, after fetching the list. The Pages addon does exactly this.

First, the mental model, because this section depends on it: the theme is **one long-running server program shared by every visitor**. Anything you register is remembered by that program until it restarts — it is not per-visitor and not per-page-load. So if your data changes, old registrations stick around unless you remove them.

That is the sharp edge here. `pano.ui.app.onLoad` runs on every request, but the routes and links you register persist in that process between requests. If a page is deleted in the panel, the entry you registered earlier lingers — SSR still serves the ghost route until the process restarts, even though the browser no longer knows about it.

The fix is to track what you registered and remove entries that are gone, using `pano.ui.page.unregister(path)`:

```js
const registeredPaths = new Set();

pano.ui.app.onLoad(async (data, event) => {
  const res = await api.get({ path: '/pages', request: event });
  const pages = res.items ?? [];
  const incoming = new Set(pages.map((p) => p.url));

  // Remove routes we registered before that are no longer present.
  for (const path of registeredPaths) {
    if (!incoming.has(path)) {
      pano.ui.page.unregister(path);
      registeredPaths.delete(path);
    }
  }

  for (const page of pages) {
    // the view file is src/theme/views/CustomPage.svelte; on the site a page names a view, not a component
    pano.ui.page.register({ path: page.url, view: 'shoutbox:CustomPage' });
    registeredPaths.add(page.url);
  }
});
```

Re-registering a page with `pano.ui.page.register` is safe: the same path simply overwrites the previous entry, so no duplicate guard is needed here — unlike nav links, which *would* duplicate, which is why `editNavLinks` needs its `some(...)` check.

::: warning Ghost routes in SSR
Dynamic pages registered from data survive in the Node process (SSR — server-side rendering — runs in one long-lived program). If you never unregister removed items, deleted pages keep serving during SSR until Pano restarts. The `pano-plugin-link-redirects` addon is the complete reference for this cleanup pattern, including removing stale nav links you added.
:::

## Where to next

The visitor side is done: a home-page widget with SSR data, API calls, and (optionally) dynamic pages.

* **Add admin screens → [Panel UI](/addon/panel-ui/)** — settings sections, full panel pages with nav links, and toasts.
* **[Frontend API Reference](/addon/api-reference/)** — every hook name, view slot, and lifecycle event in one place.
* **[Translating text](/addon/frontend/#translating-text-in-your-components)** — the `$_` helper your components use for labels.
* **[Backend Development](/addon/backend/)** — the Kotlin endpoints your `load()` and `api` calls hit.
