Settings

Packages can add entries to the Personal Settings panel without declaring routes or navigation. Each entry is a link in the settings list; clicking it renders a component the package ships. This is how settings-only packages like @tinycld/google-takeout-import integrate - no routes, no nav, just a settings array and a component file.

Declaring settings entries

Add a settings array to the manifest. Each entry has three fields:

settings: [
    {
        slug: 'example',
        label: 'Example settings',
        component: 'settings/example',
    },
],

A package can declare any number of settings entries. @tinycld/mail ships two: one for the mail provider, one for mailboxes.

settings: [
    { slug: 'provider', label: 'Provider', component: 'settings/provider' },
    { slug: 'mailboxes', label: 'Mailboxes', component: 'settings/mailboxes' },
],

The component

A settings component is a plain React component. It must default-export. The generator wires the component into the config by default name; a named export will not be picked up. Settings panels render into the settings layout that core already provides — no chrome, no title bar, just the panel body. Use the same hooks and patterns as any other screen:

import { eq } from '@tanstack/db'
import { useForm } from '@tinycld/core/ui/form'
import { useStore } from '@tinycld/core/lib/pocketbase'
import { useOrgLiveQuery } from '@tinycld/core/lib/use-org-live-query'
import { useMutation } from '@tinycld/core/lib/mutations'

export default function ExampleSettings() {
    const [settingsCollection] = useStore('example_settings')
    const { data } = useOrgLiveQuery((query, { orgId }) =>
        query.from({ s: settingsCollection }).where(({ s }) => eq(s.org, orgId)),
    )
    // render your form
    return null
}

Default-export the component. The generator imports it by default name - a named export will not be picked up.

How the generator wires it

The settings array is one of several manifest fields the generator threads end-to-end. Tracing the flow makes the mental model concrete, and it’s the same shape used for sidebar slots and sidebar:

  1. pnpm install (or pnpm run packages:generate) runs tinycld/scripts/generate.ts, which loads each present member’s manifest.ts.
  2. For every entry in settings, tinycld/scripts/gen-config.ts emits a lazy import into the generated tinycld/tinycld.config.ts:
    definePackageEntry<MailSchema>()({
        manifest: { /* ... */ },
        settings: [
            { slug: 'provider', label: 'Provider', Component: lazy(() => import('@tinycld/mail/settings/provider')) },
            { slug: 'mailboxes', label: 'Mailboxes', Component: lazy(() => import('@tinycld/mail/settings/mailboxes')) },
        ],
    })
  3. At runtime, core/lib/packages/derive-components.ts exposes packageSettings — a PackageSettingsGroup[] with one entry per package, each carrying the list of { slug, label, Component } panels declared by that manifest.
  4. The Personal Settings index iterates packageSettings to render the navigation list, and the [...section] dynamic route looks up the matching panel and renders the Component under <Suspense> so the module loads on demand.

The whole pipeline is static — no runtime registration, no useEffect-driven discovery. If a panel doesn’t appear, you can step through these four points in order to find the break.

package.json exports

The wildcard export for settings/* must be present or the generator can’t resolve the component path:

{
    "exports": {
        "./settings/*": "./tinycld/example/settings/*.tsx"
    }
}

Settings-only packages

A package with no nav entry, no screens, and no public routes is valid. @tinycld/google-takeout-import is the canonical example:

const manifest = {
    name: 'Google Takeout Import',
    slug: 'google-takeout-import',
    version: '0.1.0',
    description: 'Import data from Google Takeout .zip files.',
    settings: [
        {
            slug: 'google-takeout',
            component: 'settings/takeout',
            label: 'Import from Google',
        },
    ],
}

export default manifest

When this package is a present member, a single “Import from Google” link appears in Personal Settings. Removing the member removes the entry with no trace.

Runtime gating from a settings panel

Settings panels often need to know which other packages are installed - e.g. the takeout importer only offers “Import mail” if @tinycld/mail is present. Use usePackages() for this:

import { usePackages } from '@tinycld/core/lib/packages/use-packages'

export default function TakeoutSettings() {
    const installed = new Set(usePackages().map((p) => p.slug))
    const canImportMail = installed.has('mail')
    // conditionally render import options
}

Do not add a hard import from @tinycld/mail into the dependent package. That would turn the dependency into a build-time requirement and break the lean-shell guarantee (a checkout with no feature packages must still typecheck and run). Filter by slug at runtime instead.

Troubleshooting

SymptomLikely cause
Panel doesn’t appear in Personal Settingspnpm install / pnpm run packages:generate hasn’t re-run since the manifest change.
Build error: Module not found: @tinycld/<pkg>/settings/<name>package.json is missing the wildcard export "./settings/*": "./tinycld/<pkg>/settings/*.tsx".
Panel link clicks but renders blankThe component file uses a named export. Switch to export default.
Multiple packages clash on the same slugslug must be unique across all installed packages, not just within one manifest. Rename one of them.

See also