Client-Config Registration
The goal in one sentence: keep Byline's admin-area JavaScript and bundles — the document editor, field widgets, the richtext/AI editors, the whole admin UI — from leaking into the public surface of the host application. A visitor loading a public page (a marketing route, a blog post) should never download the admin editor. Everything below is in service of that boundary: the admin graph stays code-split behind the _byline routes, and the client config that references it is registered carefully so the admin code is pulled in only on admin routes.
This doc covers how apps/webapp/byline/admin.config.ts (the browser/SSR client config) is registered, why it's registered from two points today, the root cause that blocks collapsing those to a single eager point, and — the load-bearing question — whether an eager single-point registration is even possible given that custom slot components legitimately need React context.
Companions:
- CORE-COMPOSITION.md — the server composition story (
initBylineCore()); this doc is the client config analogue. - RICHTEXT.md — the richtext editor slot, which was the first heavy slot made lazy (the
lexicalEditorfactory; see also@byline/richtext-lexical/config). - TODO.md — priority index; this is the linked detail for "Admin client-config registration — single-point resolution."
Status (2026-06): groundwork shipped, eager single-point deferred. The richtext side is light (@byline/richtext-lexical/config, lazyAiLexicalExtension). The dual registration remains, blocked by the admin-presentation-barrel coupling described below. The eager single point is possible but its cost/benefit is currently poor — see Verdict.
What the client config is
byline/admin.config.ts calls defineClientConfig(config) as a module side-effect. That config does two structurally different jobs:
- Config data — React-free, lightweight. Collection definitions, field types, column metadata (field name, label, sortable), routes, the i18n bundles. This is the part
getClientConfig()consumers read at the loader phase. - Config component bindings — live React references. The slots that hold actual components:
fields.<type>.editor— aRichTextEditorComponent(the richtext field).columns[].formatter— aColumnFormatter: either a plain(value, document) => ReactNodefunction or a{ component }wrapper (packages/core/src/@types/admin-types.ts).listView— a full custom collection-index component (ListViewComponentProps), e.g. the media collection'sMediaListView.- per-field admin overrides (
aiRichTextAdmin(), etc.).
The whole Phase 3 problem lives in the tension between these two jobs: the data wants to register eagerly; the component bindings drag heavy React code when they do.
Why registration wants to be eager
defineClientConfig must have run before anything reads getClientConfig(). On the _byline route two distinct lifecycle moments need it, and TanStack Start covers them differently:
- Loader phase. A
_byline/*child loader (e.g. the admin dashboard loader) callsgetClientConfig(). A parent route'sbeforeLoadresolves before its children's loaders run, so registering there closes the race. On the client there is no server-config fallback, so an unregistered read throws "Byline has not been configured yet." - Component render / initial hydration. On initial hydration TanStack Start reuses the dehydrated SSR result and does not re-run
beforeLoad(or loaders), yet the admin layout component still callsgetClientConfig()at render.
A single registration point can only cover both moments if it lives in a module that is evaluated in both the loader-phase graph and the component/hydration graph — i.e. an eager top-level import in the route tree, evaluated before the router processes matches. That only works if the imported module's static graph is light; otherwise every public route pays for it.
Current solution — dual registration
Because the config graph is not light (next section), the config is kept code-split and registered from two complementary points on the _byline route, both importing byline/admin.config and both calling defineClientConfig idempotently (it evaluates once and is cached):
Point | File | Covers |
|
| Loader phase — runs before any |
side-effect |
| Component render / initial hydration — where |
This is correct and robust — both guarantees hold reliably, and the dynamic/ lazy imports keep the heavy admin/editor graph out of public-route bundles. It is only awkward: two entry points for one logical registration. There is no correctness bug here; the eager single point is an elegance/maintenance goal, not a fix.
Root cause — why the config graph isn't light
Two layers, only one of which is solved:
1. The editor runtime (solved)
admin.config referenced the richtext editor, which used to pull @byline/richtext-lexical's . barrel (statically re-exporting RichTextField / EditorField / Nodes / every extension). Shipped fix: the @byline/richtext-lexical/config subpath exports only lexicalEditor (a factory that dynamic-imports the editor on first mount), the built-in extension names, and the light toolbar-authoring primitives — no editor runtime. @byline/ai's AiLexicalExtension was likewise made statically light (the AI drawer loads via dynamic import only). So referencing the editor no longer pulls it.
2. The admin-presentation barrel (the actual blocker)
Every collection admin config statically imports presentation components from @byline/admin/react — e.g. DateTimeFormatter (in every collection), and MediaListView → LocalDateTime. @byline/admin/react is a single, deliberately indivisible barrel. Its own header explains why:
per-area subpath exports break React Context identity under bundlers that pre-bundle subpaths individually (e.g. Vite's optimizeDeps.include) — a provider mounted on one Context identity and a hook reading another. A single specifier eliminates the trap structurally.
The barrel export *s the whole admin document-editor surface — the four React contexts (FormContext, FieldServicesContext, AdminServicesContext, NavigationGuardContext), FormRenderer, FieldRenderer, every field widget, DiffModal. The column formatters are held as live references by the config objects (columns[].formatter = DateTimeFormatter), so they can't be tree-shaken away. Therefore eager-importing admin.config drags the entire admin editing interface into public-route bundles — the exact regression the dual registration exists to avoid.
This generalises: any admin component wired into a column view or custom slot has this effect whenever it comes from — or transitively imports — @byline/admin/react. Light formatters that render off @byline/core types only (e.g. FeaturedFormatter, MediaThumbnail) do not; the determining factor is "does this slot's import graph reach @byline/admin/react?", not "is it a formatter."
The wrong framing (and why "extract light components" fails)
The tempting fix — "extract the formatters into a light subpath so the config can reference them without the editor surface" — does not work for real slots, and this is exactly the concern that motivated this doc: custom slot components legitimately want React context. MediaListView needs i18n, LocalDateTime, the pager, and the field-services Context; a custom field editor needs FormContext. You cannot make those context-free, and you cannot move the context modules into a separate light subpath without re-introducing the multi-Context-identity trap the single barrel exists to prevent (two copies of createContext → provider and hook reading different objects).
So "package the slot components lighter" is a dead end for anything non-trivial.
The viable framing — defer when code loads, not where it renders
The key insight: lazy loading changes when a component module evaluates; it does not change where the component renders in the React tree, nor which contexts it can read. A slot component — however its code is loaded — renders where its slot is mounted: inside the admin shell, inside FormProvider / FieldServicesProvider / AdminServicesProvider / the i18n provider. It still imports its hooks from the same single @byline/admin/react specifier, which resolves to the same context objects the providers already mounted.
That decouples the two concerns cleanly:
- Bundle weight (Phase 3 blocker) → solved by making the config hold deferred slot bindings instead of eagerly-evaluated component modules.
- Context identity (why the barrel is indivisible) → untouched. Lazy loading a component from the same barrel does not duplicate the context modules; the single-specifier rule still holds.
So the answer to "is this even possible, given slot components need context?" is yes — because the fix isn't to strip context from slot components, it's to stop eagerly evaluating their code at registration time. Context access at render time is unaffected.
Mechanism sketches
All three keep @byline/admin/react a single barrel and keep context access intact; they differ in ergonomics.
A. Deferred slot bindings (lazy component references). The config holds a thunk, not an evaluated module:
// instead of: import { DateTimeFormatter } from '@byline/admin/react'// columns: [{ field: 'createdAt', formatter: DateTimeFormatter }]
columns: [{ field: 'createdAt', formatter: lazyFormatter(() => import('@byline/admin/react').then(m => m.DateTimeFormatter)) }]admin.config's static graph stays light (only thunks); the admin barrel loads as one shared chunk the first time any admin route renders a deferred slot. Cost: the admin render layer must wrap slot rendering in Suspense (the list cell / slot host), and the authoring API gains a wrapper. Note the dynamic import('@byline/admin/react') deliberately pulls the whole barrel as one chunk — correct for context identity, and acceptable because it only loads on admin routes.
B. Descriptor / registry tokens. The config carries a React-free token (formatter: dateTime() → { kind: 'datetime' }); the admin route owns a registry that resolves tokens to the real (lazily-loaded) components. Fully decouples config data from component code; custom components register into the admin-route registry (registerFormatter('media-thumb', () => import('./media-thumbnail'))). Strongest separation, largest API change.
C. Split registration by realm. Register the React-free config data eagerly (single point — race + hydration gap gone for the part that the loader phase actually reads), and register the component bindings from inside the _byline/admin subtree (lazy, where the barrel is already loaded). This mirrors the existing schema-vs-defineAdmin split, taken one level further: admin config metadata (eager-safe) vs. admin component bindings (admin-route-only).
Each preserves: single barrel (context identity), context access at render (slots still mount inside the provider tree), and no editor/admin surface in public bundles (component code is behind a dynamic boundary).
Verdict — possible, context-safe, but not currently worth it
An eager single-point registration is achievable without breaking context access. But weigh it honestly:
- Benefit: removes two import statements that already work correctly. No correctness or robustness gain — the dual registration's guarantees are reliable.
- Cost: reworks the formatter/
listView/editor authoring API for deferral, addsSuspenseplumbing across the admin list/slot render layer, and touches every collection admin config — for a DX regression (slots become thunks/tokens instead of plain imports).
That trade is poor today. Recommendation: keep the dual registration; defer the eager single point until a concrete driver makes eager-light config genuinely necessary rather than merely tidier. Candidate drivers:
- A non-admin/public or SSR surface needs
getClientConfig()data (routes, collection metadata, i18n) eagerly — at which point eager-light registration stops being elegance and becomes a requirement. - The loader/hydration registration ever proves flaky in practice (it has not).
- The slot/formatter API is being reworked anyway for another reason, making the deferral change marginal.
If a driver lands, mechanism C (split data-vs-bindings registration) is the recommended starting point: it gives the eager single point for the data half without forcing every slot author onto a deferral wrapper, and it keeps the context guarantees structural rather than convention-based.
Touch points (for whoever picks this up)
@byline/adminpackaging + the slot-render layer (fields/column-formatter.tsx, the collection-index list view) forSuspenseboundaries.apps/webapp/byline/admin.config.tsand the collectionadmin.tsxfiles (slot authoring shape).src/routes/_byline/route.tsx,route.lazy.tsx(retire one point), and the registration comments inclient.tsx/admin.config.ts/CLAUDE.md.