Pages and Routing Overview

ubean uses a file-based routing system. Pages are Vue components in src/pages/; API routes use Hono handlers with void-style named exports in src/routes/.

Page Components

Pages are Vue components located in src/pages/. The file structure determines the route path.

Basic Pages

src/pages/
├── index.vue           → /
├── about.vue           → /about
└── blog/
    ├── index.vue       → /blog
    └── [slug].vue      → /blog/:slug

Dynamic Routes

Use square brackets [param] for dynamic segments:

src/pages/users/[id].vue        → /users/:id
src/pages/posts/[year]/[slug].vue → /posts/:year/:slug

Access route parameters via useRouter() (from vue-router; auto-imported only when autoImports: { vueRouter: true } is enabled — off by default):

<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();

// Current route info
console.log(router.currentRoute.value.path);
console.log(router.currentRoute.value.params.id);
console.log(router.currentRoute.value.query);
console.log(router.currentRoute.value.hash);
</script>

Catch-All Routes

Use [...path] for catch-all routes:

src/pages/[...path].vue → /anything/here

Special Pages (404 / Loading)

ubean auto-detects special pages at the root of src/pages/. These are not registered as regular routes — they serve framework-level roles instead:

FileRoleEffect
pages/404.vue (or .ts / .md)Not Found pageRegisters a Vue Router catch-all /:pathMatch(.*)* and a Hono GET * fallback handler. Unmatched browser navigation returns HTTP 404 and renders this component. API (/api/*) and internal (/_*) paths still get the default JSON 404.
pages/loading.vue (or .ts / .md)Loading indicatorUsed as the <Suspense> fallback component, shown while a lazy-loaded page component is being resolved during SPA navigation. SSR skips this (server resolves async synchronously).
pages/error.vue (or .ts / .md)Error boundaryUsed as the ErrorBoundary component (Vue errorCaptured). Renders when a page’s render / setup / async resolution throws. Receives an error prop. Auto-resets on route change. Client-only.

Only root-level files are treated as special. A nested file like pages/users/404.vue remains a regular route at /users/404.

Application Root Component (src/app.vue / src/App.vue)

Separate from the pages/ specials, ubean also auto-detects an application root component at src/app.vue (lowercase, preferred — aligns with the app.ts defineApp entry) or src/App.vue (capitalized, classic Vue). When both exist, lowercase app.vue wins. It’s a wrapper above the framework root component: the layout chain + page are injected through its default slot — render it with <slot />. This is where global context providers (SConfigProvider etc.), app-wide error boundaries above layouts, or global transition containers belong. See Runtime — Application Root Component for the full contract.

Overriding via defineApp

loadingComponent, errorComponent, and appRoot can also be configured (or overridden) programmatically via defineApp:

// src/app.ts
import { defineApp } from 'ubean';
import MyLoading from './components/MyLoading.vue';
import MyError from './components/MyError.vue';
import MyAppRoot from './components/MyAppRoot.vue';

export default defineApp({
  // Overrides pages/loading.vue auto-detection
  loadingComponent: MyLoading,

  // Overrides pages/error.vue auto-detection
  errorComponent: MyError,

  // Overrides src/app.vue / src/App.vue auto-detection (wrapper above layouts + page)
  appRoot: MyAppRoot
});

Priority: defineApp({ loadingComponent / errorComponent }) > pages/loading.vue / pages/error.vue auto-detection. For appRoot: defineApp({ appRoot }) > src/app.vue > src/App.vue auto-detection.

errorComponent wraps the page content in an error boundary (using Vue’s errorCaptured lifecycle). The error state auto-resets when the route changes, so navigating away from a broken page clears the boundary.

Route Groups

Directories wrapped in parentheses don’t contribute URL segments:

src/pages/(marketing)/about.vue → /about  (group "marketing" ignored)

Parentheses with dots ((.)photo/, (..)photo/, (...)photo/) are a different convention — Next.js’s intercepting routes — and ubean deliberately does not support it. The scanner rejects those segments with an error rather than registering a route at a literal /(.)photo path. See Dialogs and shareable URLs for what to do instead.

Parallel Routes (@slotName/)

A directory starting with @ names a slot: pages inside it share the same URL as the page next to them and are registered as Vue Router named views on that route record, so one URL can render several page components into different places in the layout.

src/pages/photo/[id].vue          → default view of /photo/:id
src/pages/@dialog/photo/[id].vue  → "dialog" named view of the same route

Render a slot from a layout (or any ancestor component) with <SlotView>:

<!-- src/layouts/default.vue -->
<template>
  <main>
    <slot />
  </main>
  <SlotView name="dialog" />
</template>

<SlotView name="x" /> resolves the component from the deepest matched route record that exposes that slot, and renders nothing when there is none. The slot page is its own page file: it has its own definePage, its own loader, and its own client chunk.

Dialogs and shareable URLs

Intercepting routes exist in Next.js to show a dialog for another route while keeping the current page mounted behind it and putting the target route’s URL in the address bar. ubean deliberately doesn’t implement that convention (see ADR-0010): making the server render the full target page while the client renders a dialog means an intentional SSR/client divergence, and the page behind the dialog remounts instead of being preserved. Pick one of these instead:

1. Keep the dialog off the router (most apps). A dialog that doesn’t need its own URL is just component state — you get a preserved background for free and no SSR/client split:

<script setup lang="ts">
const selected = shallowRef<Photo | null>(null);
</script>

<template>
  <PhotoGrid @select="selected = $event" />
  <PhotoDialog v-if="selected" :photo="selected" @close="selected = null" />
</template>

2. Put the dialog in a slot and drive it with a query flag when you do want the state in the URL (deep links, back button closes it). The URL stays the target route’s URL, plus a marker:

src/pages/photo/[id].vue            → the full page (default + hard navigation)
src/pages/@dialog/photo/[id].vue    → the dialog (slot "dialog")
src/layouts/default.vue             → <SlotView v-if="route.query.dialog" name="dialog" />
<!-- link from the list -->
<Link :to="{ path: `/photo/${photo.id}`, query: { dialog: '1' } }">{{ photo.title }}</Link>

Unlike interception this is SSR-consistent (server and client both render the page plus the dialog) and back/forward work through normal URL history. The trade-off to know about: the background is the target page itself, not the list you came from — if you need the list to stay visible, use option 1. Since the dialog URL is a real, crawlable URL, add a canonical or noindex for it if you don’t want it in search results.

Layouts

Layout components wrap pages and provide consistent UI across routes.

Default Layout

Create src/layouts/default.vue:

<template>
  <header>
    <nav>
      <Link to="/">Home</Link>
      <Link to="/about">About</Link>
    </nav>
  </header>
  <main>
    <slot />
  </main>
</template>

<Link> is globally registered — no import needed.

Custom Layouts

Specify a layout per page via definePage:

<script setup lang="ts">
// definePage is a compile-time macro, auto-imported
definePage({
  layout: 'admin'
});
</script>

Create src/layouts/admin.vue (or src/layouts/admin/index.vue) for the admin layout. Layouts support nested chains (e.g. admin/dashboard → admin → default).

Page Metadata (definePage)

definePage is a compile-time macro for configuring page options. Its top-level fields are name, path, layout (string | string[] | false), reuse, meta, requiresAuth, cache, head, ssr (boolean | 'streaming' | 'data-only'), transition. There is no top-level title — use head.title or meta: { title }. There is no Nuxt-style client middleware/*.global file convention: register vue-router guards in defineApp({ router: { setup } }).

<script setup lang="ts">
definePage({
  name: 'About',                   // Route name (PascalCase)
  layout: 'default',               // Layout name
  ssr: true,                       // false | 'data-only' | true | 'streaming'
  meta: {                           // Custom metadata
    title: 'My Page',
    description: 'My page description'
  },
  requiresAuth: true,               // Auth requirement (meta shortcut)
  cache: true,                      // Enable KeepAlive page caching
  head: {                           // Per-page head tags (@unhead/vue)
    title: 'My Page',
    meta: [{ name: 'description', content: '...' }]
  }
});
</script>

Page Caching (KeepAlive)

Set cache: true in definePage to preserve the page component instance with Vue’s <KeepAlive> when navigating away. When the user navigates back, the page is restored from cache instead of being re-mounted — local state (form input, scroll position, etc.) is preserved.

The framework automatically wraps the page component with a named wrapper (getNamedPageWrapper) using the route name. This means <script setup> SFCs work out of the box — you do not need to manually call defineOptions({ name: 'About' }) to make KeepAlive’s include filter match.

<!-- src/pages/about.vue -->
<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue';

definePage({ cache: true });

onActivated(() => {
  // Fires when navigating back to this cached page
  console.log('About re-activated');
});

onDeactivated(() => {
  // Fires when navigating away (component is kept alive, not unmounted)
  console.log('About deactivated');
});
</script>

<template>
  <div>About Page</div>
</template>

When cache: true is set, use onActivated / onDeactivated instead of onMounted / onUnmounted for lifecycle logic that should fire on every visit.

Runtime cache control

Use the auto-imported cache helpers to toggle caching at runtime (e.g. from a layout or a settings panel):

<script setup lang="ts">
// All auto-imported from ubean/client
const { cachedViews, excludedViews } = useCacheViews();

function toggleCache(name: string) {
  if (cachedViews.value.includes(name)) {
    disablePageCache(name);
  } else {
    enablePageCache(name);
  }
}

// Force-remove a cached instance so it re-mounts next visit
invalidatePageCache('About');

// Reload the current cached page (exclude → wait → include, forces remount)
await resetRouteCache('About');
</script>

Available runtime helpers (all auto-imported from ubean/client):

FunctionDescription
useCacheViews()Reactive store with cachedViews / excludedViews / enabled
enablePageCache(name)Add a route name to the cache include list
disablePageCache(name)Remove a route name from the cache (prunes its instance)
excludePageCache(name)Temporarily exclude a page from cache (forces prune next render)
includePageCache(name)Restore caching for an excluded page
invalidatePageCache(name?)Invalidate a specific page, or all pages when omitted
isPageCached(name)Check whether a page is currently cached
resetRouteCache(name?, delay)Reload a cached page by exclude → wait → include (forces remount)

Reuse route cache inheritance

When a .reuse.ts page does not explicitly declare cache, it inherits the target page’s cache setting. This means if the target page has cache: true, the reuse route is automatically cached too — each as an independent KeepAlive instance keyed by its own route name.

// pages/about.vue — cache enabled
definePage({ cache: true });

// pages/about2.reuse.ts — inherits cache: true automatically
definePage({ reuse: 'About' });

// pages/about3.reuse.ts — explicitly disable cache (overrides inheritance)
definePage({ reuse: 'About', cache: false });
Reuse page cache valueBehavior
undefined (not declared)Inherit from target page
trueExplicitly enable cache (even if target is not cached)
falseExplicitly disable cache (even if target is cached)

Navigation

<Link> Component (Globally Registered)

<Link> is auto-registered as a global Vue component — no import needed:

<template>
  <Link to="/">Home</Link>
  <Link to="/about">About</Link>
  <Link to="/users/123">User 123</Link>
  <Link :to="{ name: 'UserDetail', params: { id: '123' } }">User detail</Link>
  <Link :to="{ path: '/search', query: { q: 'ubean' } }">Search</Link>
</template>

<Link> props:

PropTypeDescription
tostring | { name, params, query, hash }Target route
localestringLocalize the target path to a locale (i18n)
replacebooleanUse router.replace instead of push
hrefstringOverride the rendered href
prefetchbooleanPrefetch the target page chunk
activeClassstringClass applied when link is active
exactActiveClassstringClass for exact active match
noActiveClassbooleanDisable the default active classes

The default slot also exposes isActive / isExactActive for advanced use.

Programmatic Navigation (useRouter)

<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();

function goAbout() {
  router.push('/about');
}

function replaceWithLogin() {
  router.replace('/login');
}

function goBack() {
  router.back();
}
</script>

External URLs

For external URLs, use plain <a> tags:

<template>
  <a href="https://example.com" target="_blank" rel="noopener">External link</a>
</template>

Middleware

Middleware runs before page or API routes. Files live in src/middleware/:

// src/middleware/auth.ts
import { defineMiddleware } from 'ubean/server';

export default defineMiddleware(async c => {
  const user = c.get('user');
  if (!user) {
    return c.redirect('/login');
  }
});

Ordering Rules

  • global.ts or global.*.ts → mounted at /*
  • Files with numeric prefix (e.g. 01-auth.ts, 02-logging.ts) → ordered by prefix
  • Directory-prefixed middleware (e.g. middleware/admin/auth.ts) → mounted at /admin/*

Navigation Guards (Client + SSR)

ubean exposes vue-router’s global navigation guards (beforeEach / beforeResolve / afterEach) via defineApp({ router }) in src/app.ts. Guards run on both client and SSR, so they can intercept the initial navigation (including SSR’s router.push(initialUrl)).

// src/app.ts
import { defineApp } from 'ubean';

export default defineApp({
  router: {
    setup(router) {
      // Auth guard — redirect to /login if route requires auth
      router.beforeEach((to, from) => {
        if (to.meta.requiresAuth && !isAuthenticated()) {
          return '/login';
        }
      });

      // Analytics / page title — runs after navigation completes
      router.afterEach((to) => {
        if (typeof document !== 'undefined' && to.meta?.title) {
          document.title = String(to.meta.title);
        }
      });
    }
  }
});

Execution timing

setup(router) is called after the router instance is created and before app.use(router). This ensures guards are registered before the first navigation kicks off, so they can intercept the initial URL on both client hydration and SSR rendering.

Accumulation across app.ts + app.server.ts / app.client.ts

If you split your config into app.ts (shared) + app.server.ts and/or app.client.ts, all setup functions are chained (shared first, then client/server-specific). This lets you put common guards (e.g. analytics) in app.ts and environment-specific guards (e.g. SSR-only auth redirects) in app.server.ts.

Constraints

  • setup itself must register guards synchronously (the guard functions can return Promises).
  • Keep setup free of await API calls — they delay the first navigation. Put async logic inside the guard callback instead.

Guards vs backend middleware

AspectFrontend guards (router.setup)Backend middleware (src/middleware/)
TriggerClient-side navigation + SSR URLEvery HTTP request
Runs onBrowser + SSR runtimeServer (Hono)
Typical useAuth redirect, page title, analyticsCookie parsing, session injection, CORS
Network round-tripNoYes

Auth flows that need cookie/header inspection typically live in backend middleware (which injects user into context); frontend guards then read that state and decide whether to redirect. Do not add a second client middleware file tree.

Route Rules

Define route-level rules in ubean.config.ts:

import { defineConfig } from 'ubean';

export default defineConfig({
  routeRules: {
    '/api/**': {
      headers: {
        'Access-Control-Allow-Origin': '*'
      }
    },
    '/admin/**': {
      headers: { 'Cache-Control': 'private' }
    },
    '/static/**': {
      cache: { ttl: 3600 }
    }
  }
});

Supported rule fields (processed in order: redirect > rewrite (internal rematch) > proxy > headers (merged) > cache):

  • * matches a single path segment
  • ** matches multiple segments recursively

Per-Route Rendering Rules (P9-03)

In addition to HTTP-level rules, routeRules can override rendering behavior per route. This aligns with Nuxt’s routeOptions and Astro’s export const prerender:

export default defineConfig({
  routeRules: {
    // Force CSR for admin pages (overrides global ssr: true)
    '/admin/**': { ssr: false },

    // Force SSR for a specific page (overrides ssr.exclude)
    '/dashboard/realtime': { ssr: true },

    // Streaming SSR for this route (overrides global streaming setting)
    '/feed': { ssr: 'streaming' },

    // ISR: regenerate every 60s, serve stale while revalidating
    '/blog/**': { isr: { ttl: 60, swr: true } },

    // ISR with simple ttl form (no SWR)
    '/news/**': { isr: 300 },

    // Prerender at build time (auto-discovered by the prerenderer)
    '/about': { prerender: true },
    '/blog/**': { prerender: true }
  }
});
FieldTypeDescription
ssrboolean | 'streaming' | 'data-only'Override global SSR setting for matching routes. false → CSR, true → SSR, 'streaming' → streaming SSR, 'data-only' → loaders run but return a CSR shell with dehydrated data.
prerenderbooleanMark route for build-time prerendering. Auto-discovered by prerender() from routeRules.
isrnumber | { ttl: number; swr?: boolean }Incremental Static Regeneration. ttl in seconds; swr: true serves stale content while revalidating.

Specificity & merging: Rules are sorted by a combined score (rule-type weight + path-segment weight). For a given request, the matched rule is exposed to handlers via c.get('routeRule') and drives:

  • ssr / ssr: 'streaming' → overrides ssr.exclude and SsrOptions.streaming for that route
  • isr → GET requests are served from ISR cache (HIT / STALE / MISS, marked via X-ISR header); the renderer runs on MISS (and on STALE when swr is enabled, in the background)
  • prerender → collectPrerenderRoutes() automatically collects these patterns at build time (merged with prerender.include / prerender.all)

See also Cache Operations for CacheStore.peek() and ISR internals.

Data Fetching

Use useData() (auto-imported from ubean/client) for declarative data fetching with caching, TTL, dependencies and invalidation:

<script setup lang="ts">
const { data, error, loading, refresh, invalidate } = await useData({
  key: 'posts',
  fetcher: () => fetch('/api/posts').then(r => r.json())
});
</script>

Next Steps