Routing
Org-scoped routes live under /a/[orgSlug]/.... The [orgSlug] segment is dynamic — it’s the slug of the org the user is currently viewing. Hard-coding paths like /contacts/new or /todo/123 either drops the org segment entirely (broken navigation) or, worse, sends the user to a different org’s data.
Use useOrgHref() from @tinycld/core/lib/org-routes for every push, replace, and <Link> inside an org-scoped screen.
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 org root — no leading /a/[orgSlug] prefix — plus optional dynamic params. It returns an Expo Router Href object with the current org’s slug filled in.
What NOT to do
// ❌ Literal path, drops org context
router.push('/todo/new')
// ❌ Literal path, even worse — silently sends to a non-existent route
router.push(`/${orgSlug}/todo/new`)
// ❌ Manually concatenating — easy to typo, no compile-time check
router.push(`/a/${orgSlug}/todo/new`)
All three either break (missing /a/ prefix), leak across orgs (when the literal path doesn’t include the slug), or silently break when the org segment is misspelled.
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/<orgSlug>/mail?folder=sent
When to use literal paths
Public routes — the kind declared via the manifest’s publicRoutes field — live outside the org tree, namespaced under /p/<slug>/. Drive’s share-link landing page is the canonical example:
// Public page; no org context exists yet
router.push(`/p/drive/share/${token}`)
Same for the auth flow: /setup, /connect, /accept-invite/[token] — these are pre-auth routes and orgHref doesn’t apply.
If the route lives at /a/[orgSlug]/..., it’s org-scoped and you use orgHref. Everything else is a literal path.
Navigating between orgs
Switching the user from org A to org B isn’t routing — it’s a same-origin redirect. Use navigateToOrg(orgSlug) from @tinycld/core/lib/org-url:
import { navigateToOrg } from '@tinycld/core/lib/org-url'
navigateToOrg('other-org') // does a path navigation to /a/other-org
This rebuilds the entire route stack; org-scoped state from the previous org doesn’t leak in.
Testing
useOrgHref() reads the org slug from context (web) or AsyncStorage (native). In unit tests, mock useOrgSlug to return a fixed value — the helpers in tests/unit.helpers.tsx do this for you.