Routing

The file system is the router.

Every .alpine file under pages/ is a route. The path on disk becomes the URL — with index collapsing to the parent and [param] segments matching dynamically.

The mapping #

scanPages walks pages/**/*.alpine and turns each file into a route. A trailing index is dropped, and [name] becomes a :name parameter:

FileURLKind
pages/index.alpine/static
pages/about.alpine/aboutstatic
pages/blog/index.alpine/blogstatic
pages/blog/[slug].alpine/blog/:slugdynamic

Nesting works to any depth — folders become path segments. Generate a page with apex make page blog/hello.

Index routes #

index.alpine represents the folder it sits in. pages/index.alpine is the site root /; pages/blog/index.alpine is /blog.

Dynamic [param] segments #

Wrap a segment in square brackets to capture it. The matched value is passed to the page's loader() as params:

pages/blog/[slug].alpine

<script server lang="ts">
  export function loader({ params }: { params: Record<string, string> }) {
    return { slug: params.slug }
  }
</script>

<template x-data>
  <main>
    <h1 x-text="'Post: ' + slug"></h1>
    <a href="/">← Home</a>
  </main>
</template>

Requesting /blog/hello-world calls the loader with params.slug === "hello-world". Segment values are URL-decoded for you.

Dynamic routes need a server at build time

apex dev and apex start render dynamic routes per request. A plain apex build / apex build --islands prerenders only static routes and reports the dynamic ones it skipped — build with --server to serve them in production. See Build & deploy.

Precedence #

Routes are matched by segment count and then literal-vs-param. Static routes take precedence over dynamic ones, so pages/blog/new.alpine (/blog/new) wins over pages/blog/[slug].alpine for the exact path /blog/new.

404s #

A request that matches no route returns a 404. Keep URLs relative in your own links (Apex is happy under a sub-path), and let unmatched paths fall through to the framework's not-found response.

Client-side navigation #

Links between your pages are upgraded to in-place navigation: Apex fetches the target page, swaps the content, merges its <head> (title + meta), and re-runs Alpine — with no full reload, so Alpine, stores, and the runtime stay alive between pages. It's on by default; there's nothing to wire up.

  • Prefetch — pages are fetched on link hover / viewport entry, so the click is instant.
  • Progress bar — a thin bar animates across the top during a navigation.
  • History & scroll — back/forward work, and scroll position is restored.

Add a pages/loading.alpine and Apex shows it in the page region during a slow navigation (a slow loader()) — a loading boundary.

Opt out where you want a real browser navigation:

<!-- full page load, no swap -->
<a href="/download" data-apex-no-nav>Download</a>

<!-- navigated normally, but never prefetched -->
<a href="/report" data-apex-no-prefetch>Heavy report</a>

Cross-origin links, downloads, and modified clicks (⌘/Ctrl-click, new tab) always fall back to a normal load. To disable the whole feature, set clientNav: false in apex.config.ts.

Islands mode still uses full loads

Client-side navigation applies to the standard SSR/hydration build. Pages built with apex build --islands navigate with full page loads.