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.

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.