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.