Routing

App routes live under /a/.... The /a segment is a constant — it namespaces the app’s own routes so they can never collide with a protocol mount (/dav, /caldav), the public share tree (/p), or a marketing path. It is not a workspace slug: a deployment is one workspace on its own host, so nothing is interpolated into it.

Use useOrgHref() from @tinycld/core/lib/org-routes for every push, replace, and <Link> inside the app. Hard-coding /todo/123 skips the prefix and lands on +not-found; hard-coding /a/todo/123 works until the prefix ever moves, and then every literal has to be found by hand.

The pattern

import { useOrgHref } from '@tinycld/core/lib/org-routes'
import { Link, router } from 'expo-router'

export default function TodoIndex() {
    const orgHref = useOrgHref()

    return (
        <View>
            <Pressable onPress={() => router.push(orgHref('todo/new'))}>
                <Text>New todo</Text>
            </Pressable>

            <Link href={orgHref('todo/[id]', { id: someTodoId })}>
                <Text>View todo</Text>
            </Link>
        </View>
    )
}

orgHref() takes a short path relative to the app root — no leading /a — plus optional dynamic params, and returns an Expo Router Href.

Outside a component (a route resolver, a redirect helper) use the plain appHref(path) from the same module. useOrgHref delegates to it, so both share one definition of the prefix.

What NOT to do

// ❌ Literal path, misses the app prefix — resolves to +not-found
router.push('/todo/new')

// ❌ Hardcodes the prefix; won't follow if it ever changes
router.push('/a/todo/new')

// ❌ Manual concatenation — easy to typo, no compile-time check
router.push(`/a/${'todo'}/new`)

Dynamic params

Wrap the param name in [brackets] in the path argument and pass the value through the second argument:

router.push(orgHref('todo/[id]', { id: todoId }))
router.push(orgHref('mail/[folder]/[id]', { folder: 'inbox', id: threadId }))
router.push(orgHref('settings/[...section]', { section: ['mail', 'provider'] }))

Catch-all params ([...section]) take an array.

Plain query params (no bracket in the path) work too:

router.push(orgHref('mail', { folder: 'sent' }))
// → /a/mail?folder=sent

orgHref returns a plain string when there are no params and an object only when params are present. That distinction is deliberate: an object href is a new identity on every render, which makes <Redirect> re-navigate forever. Don’t “simplify” it into always returning an object.

When to use literal paths

Public routes — declared via the manifest’s publicRoutes field — live outside the app tree, namespaced under /p/<slug>/. Drive’s share-link landing page is the canonical example:

// Public page; reachable without a session
router.push(`/p/drive/share/${token}`)

Protocol mounts (/dav, /caldav, /carddav) and the API (/api) are likewise outside the app prefix.

Pre-auth screens (/a/connect, /a/setup, /a/accept-invite/[token], /a/reset-password/[token]) are under /a and should be reached via the exported CONNECT_HREF constant or appHref, not written out by hand.

Switching servers

A user can hold several saved servers on native and switch between them from the More drawer. That isn’t routing — each server is its own origin, so it’s an origin change, not a path change, and navigate-to-origin in core handles it. Package code never needs to participate.

Testing

useOrgHref needs no context or mocking — call it and assert on what it returns. If a test does stub it, have the stub delegate to the real appHref rather than inlining the prefix, so the fake can’t drift from the app’s actual route shape.