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',
},
],
slug- URL segment appended under/a/[orgSlug]/settings/. Must be unique across all installed packages.label- text shown in the settings sidebar.component- subpath (no extension) to the.tsxthat renders the panel.
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:
pnpm install(orpnpm run packages:generate) runstinycld/scripts/generate.ts, which loads each present member’smanifest.ts.- For every entry in
settings,tinycld/scripts/gen-config.tsemits a lazy import into the generatedtinycld/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')) }, ], }) - At runtime,
core/lib/packages/derive-components.tsexposespackageSettings— aPackageSettingsGroup[]with one entry per package, each carrying the list of{ slug, label, Component }panels declared by that manifest. - The Personal Settings index iterates
packageSettingsto render the navigation list, and the[...section]dynamic route looks up the matching panel and renders theComponentunder<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
| Symptom | Likely cause |
|---|---|
| Panel doesn’t appear in Personal Settings | pnpm 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 blank | The component file uses a named export. Switch to export default. |
| Multiple packages clash on the same slug | slug must be unique across all installed packages, not just within one manifest. Rename one of them. |
See also
- Sidebar slots — same lifecycle (manifest-declared, lazy-loaded, generator-validated) for contributing UI into another package’s sidebar instead of into Personal Settings.