> ## Documentation Index
> Fetch the complete documentation index at: https://fymo.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Controllers and Routing

> How a URL turns into a matched route, a controller call, and a rendered Svelte template.

```python app/controllers/posts.py theme={null}
from app.remote.posts import get_post, get_comments, get_reactions

def getContext(id: str = ""):
    post = get_post(id)
    return {
        "post": post,
        "initial_comments": get_comments(id),
        "initial_reactions": get_reactions(id),
    }

def getDoc():
    return {"title": "Post"}
```

That controller pairs with a template at `app/templates/posts/show.svelte`. Here's the flow: the router resolves a request path to a controller and an action, then imports the matching file from `app/controllers/`.

It calls `getContext()` with whatever route params that function accepts, and the return value becomes the props for the matching `.svelte` file. `getDoc()` runs alongside it, supplying the page title and `<head>` content.

## Route matching

Routes come from `fymo.yml`. A `root` entry and a list of `resources` cover the common case:

```yaml fymo.yml theme={null}
routes:
  root: index.index
  resources:
    - posts
    - tags
```

`Router._expand_resources()` turns each resource name into four RESTful routes, each with a `{controller}/{action}.svelte` template:

| Path              | Controller.action | Template             |
| ----------------- | ----------------- | -------------------- |
| `/posts`          | `posts.index`     | `posts/index.svelte` |
| `/posts/:id`      | `posts.show`      | `posts/show.svelte`  |
| `/posts/:id/edit` | `posts.edit`      | `posts/edit.svelte`  |
| `/posts/new`      | `posts.new`       | `posts/new.svelte`   |

An app doesn't have to define every one of these templates, only the ones it actually renders. The blog example only ships `posts/show.svelte`, so `/posts` and the others simply aren't reachable in that app.

`Router.match()` checks routes in order: an exact path match first, then a pattern with a `:param` segment, and finally a convention-based fallback for anything not declared explicitly. The fallback covers two shapes:

| Path shape           | Resolves to         |
| -------------------- | ------------------- |
| `/controller`        | `controller.index`  |
| `/controller/action` | `controller.action` |

That same fallback is how a bare slash route resolves to `home.index`, or whatever the config's `root:` entry points at, with no separate resource entry needed.

<Note>
  `Router.add_route()` lets an app register a path directly, without going through `resources`, for routes that don't fit the RESTful shape.
</Note>

## Protecting a route

A route entry can carry `require_auth: true`, or a dotted path to a guard function for a more specific check. Either one requires the dict form of that route entry, with `to:` naming the controller action:

```yaml fymo.yml theme={null}
routes:
  root:
    to: home.index
    require_auth: true
  signin: signin.index
```

A resource entry works the same way, as a dict with `name:` required:

```yaml fymo.yml theme={null}
routes:
  resources:
    - posts
    - name: admin
      require_auth: app.auth.guards.require_admin
```

The route named `signin` is the redirect target by convention, and it's always public: `require_auth` on it is stripped at boot, with a warning printed if it's set. Any protected route with no `signin` route registered fails at boot with a `ConfigurationError` naming the fix.

An anonymous request to a protected route gets a `302` to `signin?next=<original path>`, before any SSR work starts. An authenticated request proceeds as normal. When `require_auth` is a dotted path instead of `true`, the signed-in check runs first, then the guard is imported and called with no arguments inside the request scope; any exception it raises redirects the same way as an anonymous visitor.

<Note>
  A convention-alias route (hitting `/home` when only `root` is declared protected) inherits the most restrictive `require_auth` declared anywhere for that controller. An alias can't be used to bypass protection on the routes that share its controller.
</Note>

## Route params

A segment like `:id` in a route pattern gets captured by name. Fymo passes it to `getContext()` as a keyword argument, filtered down to whatever the function actually declares in its signature:

```python app/controllers/posts.py theme={null}
def getContext(id: str = ""):
    # id is the :id segment from /posts/:id, e.g. a post slug
    ...
```

Extra params in the match that `getContext()` doesn't accept are simply dropped, instead of raising a `TypeError`.

<Note>
  This filtering happens in `fymo/core/ssr_controller.py`, in a helper called `load_controller_context()`. It uses `inspect.signature` under the hood, and both the full page render and the soft nav data endpoint call the same helper. That's why params behave identically whether it's the first load or a client-side navigation.
</Note>

## getContext and getDoc

A controller module can export two functions, both optional. `getContext(**params)` returns a dict that becomes the template's props, available as `$props()` in the Svelte component.

It can return more than plain JSON. Values can include remote functions imported from `app/remote/*`, which get serialized as callable markers that the client resolves through `$remote`.

`getDoc()` returns a dict of page metadata:

* `title` sets the `<title>` tag.
* An optional `head` dict can include `meta` and `link` lists, which become `<meta>` and `<link>` tags.

```python app/controllers/index.py theme={null}
def getContext():
    posts = get_posts()
    return {"hero": posts[0] if posts else None, "posts": posts[1:]}

def getDoc():
    return {
        "title": "Fymo Blog",
        "head": {"meta": [{"name": "description", "content": "A demo blog"}]},
    }
```

Neither function is required. A controller with no `getContext()` just renders its template with no props. A controller with no `getDoc()` falls back to the app's configured name as the title.

When identity resolvers are registered, both functions run inside a read-only request scope. This lets `current_uid()` resolve the session during server-side rendering itself, not just after the client hydrates. Fymo opens this same scope for both a full page render and a soft nav data fetch. That's what keeps a logged-in user from flashing as logged out during client-side navigation.

## Redirecting from a controller

`getContext()` can raise `Redirect` to send the visitor elsewhere instead of returning props. During a full page render this produces a real `30x` response with a `Location` header, no template gets rendered at all:

```python app/controllers/posts.py theme={null}
from fymo.remote import Redirect

def getContext(id: str = ""):
    post = get_post(id)
    if post is None:
        raise Redirect("/posts")
    return {"post": post}
```

`Redirect` is the same primitive a remote function raises to redirect the client after an action. From a controller it produces the `30x` response above; from a remote function it produces a `{"type": "redirect", ...}` envelope that the client runtime turns into a navigation. Either call site takes a location and an optional status, defaulting to `303`.

## Layouts

An optional `app/templates/_layout.svelte` wraps every route in the app, and an optional `app/templates/<resource>/_layout.svelte` wraps every route under that resource. A layout receives its page content through Svelte's `children` snippet:

```svelte app/templates/posts/_layout.svelte theme={null}
<script lang="ts">
  let { children } = $props();
</script>

<p class="kicker"><a href="/">back</a></p>
{@render children()}
```

Each `_layout.svelte` can be paired with a matching `_layout.py` controller. The path depends on the level:

* Root layout controller: `app/controllers/_layout.py`
* Resource layout controller: `app/controllers/<resource>/_layout.py`

That controller exports its own `getContext()` and `getDoc()`, called the same way as a page controller.

A route with no layout files just gets flat props, whatever its own `getContext()` returned. A route with a layout chain gets nested props instead, combining the layout's data with the page's.

<Accordion title="How layout props and metadata merge">
  When a route has a layout chain, its props come nested rather than flat: `{ leafProps, layoutProps: { root, resource } }`.

  Head metadata merges too, starting from the root layout, then the resource layout, then the leaf page. Scalar keys like `title` get overridden by the more specific level, so the leaf's title wins.

  `head.meta` and `head.link` arrays work differently: they concatenate instead of replacing. A leaf's page-specific `<meta>` tags add to the root layout's defaults, rather than replacing them.
</Accordion>

## Soft navigation

By default, clicking between routes in a Fymo app skips the full page reload. The client fetches `GET /_fymo/data/<path>` instead, which resolves the same route and runs the same controller and layout chain as a normal page load.

That request returns the next page's props, doc metadata, and bundle URLs, packed into a devalue-encoded payload. The client swaps in the new leaf, and the layout too if it changed, without tearing down the rest of the page.

<Tip>
  A resource can opt out of soft navigation per route, forcing full page reloads for it instead:

  ```yaml fymo.yml theme={null}
  routes:
    resources:
      - posts
      - name: admin
        soft_nav: false
  ```
</Tip>

<Accordion title="What happens when a stale client still asks for soft nav data">
  `Router.soft_nav_enabled(controller)` defaults to true for any controller not listed with `soft_nav: false`.

  If a browser tab was already open before soft nav got disabled, it might still request `/_fymo/data/admin`. Fymo answers with a `409 soft_nav_disabled` error instead of serving data, since that resource always wants a full reload.
</Accordion>

## The route store

Soft navigation swaps in a new leaf without a full reload, which means a component can't always rely on a fresh mount to know it's on a new page. `$route` gives any component the current route reactively, without passing it down through props:

```svelte theme={null}
<script>
  import { route } from '$route';
</script>

<p>{$route.pathname}</p>
```

It resolves to `{ pathname, search, params }`. `params` holds the same route segments a controller's `getContext()` receives, like `:id` from `/posts/:id`. The store is seeded before hydration runs, so its value is correct from first paint, and it updates on every soft nav afterward.

## Head markup with svelte:head

`getDoc()` still owns the data-driven parts of `<head>`, the title and meta tags that change with the page. For markup that's the same on every render of a template, a favicon link or a font preconnect, `svelte:head` inside a component is the simpler tool:

```svelte app/templates/_layout.svelte theme={null}
<svelte:head>
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</svelte:head>
```

The same tag works for things like Google Fonts preconnects in a shared `Nav.svelte`, or any other static tag a component wants to contribute to `<head>` regardless of which controller rendered the page. The rule of thumb: if a controller needs to decide the content, it belongs in `getDoc()`. If it's fixed markup tied to a template, `svelte:head` is more direct.
