feat: admin framework adapter pattern and tanstack support#16139
feat: admin framework adapter pattern and tanstack support#16139
Conversation
|
Hey, I am excited about this work! I had experimented with a similar approach a few months ago. I'm sure you might be aware that TanStack Start is planning to add RSC support soon (there is an draft blog post here, not sure how up to date it is with their current plans). I was wondering if this would make a difference in the approach to the framework adapter pattern, and if RSCs in Start make this significantly easier. If you haven't already, it might be good to have some communication with the TanStack team about this. Thanks for your work on this! |
I'm aware, currently I want to see if it is possible so we don't rely on whether a framework supports RSC or not (while still maintaining 100% the same approach in Next.js). This is a bit more complex indeed but would allow room for any React framework (or even a custom one on top of Vite), not just Next/Tanstack. In this case - when Tanstack will add RSC support and if we want to use it - the only place we'd have modify is the adapter itself. |
Add RouterAdapter, ServerAdapter, ComponentRenderer, and DevReloadStrategy type contracts in packages/payload/src/admin/adapters.ts. These types form the foundation for decoupling the admin panel from Next.js.
…imports - Create RouterAdapter pattern: adapter is a React component that wraps children and populates RouterAdapterContext with framework-specific values - Replace all 41 files importing from next/navigation.js, next/link.js, and next/dist/* with framework-agnostic RouterAdapter equivalents - Replace AppRouterInstance type with RouterAdapterRouter from payload - Replace ReadonlyRequestCookies with CookieStore from payload - Replace LinkProps from next/link with LinkAdapterProps from payload - Remove next from packages/ui peerDependencies - Wire RouterAdapter component into RootProvider - Export RouterAdapterContext from client entrypoint
- Create NextRouterAdapter component that calls Next.js hooks (useRouter, usePathname, useSearchParams, useParams) and populates the framework-agnostic RouterAdapterContext - Wire NextRouterAdapter into RootLayout as the RouterAdapter prop - Export NextRouterAdapter from @payloadcms/next/client
Move pure routing utilities from packages/next/src/views/Root/ to packages/ui/src/utilities/routeResolution/: - isPathMatchingRoute, getDocumentViewInfo, attachViewActions - getCustomViewByKey, getCustomViewByRoute - Shared ViewFromConfig type Original files in packages/next re-export from @payloadcms/ui for backward compatibility. getRouteData.ts updated to import from shared.
Move framework-agnostic presentational components from packages/next: - MinimalTemplate (template + styles) → packages/ui/src/templates/Minimal/ - FormHeader (element + styles) → packages/ui/src/elements/FormHeader/ Original locations in packages/next now re-export for backward compat.
Create serverFunctionRegistry in packages/ui with framework-agnostic handlers (form-state, table-state, copy-data-from-locale, etc.). packages/next handleServerFunctions now spreads the shared registry and adds RSC-specific handlers (render-document, render-list, etc.).
Create a client-only component renderer that treats all components as client components and never passes serverProps. This is the alternative to RenderServerComponent for frameworks without RSC support.
Add candidateDirectories parameter to resolveImportMapFilePath, allowing framework adapters to specify their own directory patterns instead of defaulting to Next.js app/(payload) convention. The default behavior is unchanged for backward compatibility.
Remove import of Metadata from 'next' in packages/payload config types. Define AdminMeta type that covers the commonly-used metadata subset (title, description, openGraph, icons, twitter, keywords). MetaConfig now intersects with AdminMeta instead of Next.js Metadata. The Next.js adapter can map AdminMeta to Next.js Metadata as needed.
Replace @next/env dependency with dotenv + dotenv-expand for framework-agnostic .env file loading. The new implementation supports the same file priority convention (.env.local, .env.development, etc.) without requiring Next.js packages.
Replace hardcoded Next.js webpack-hmr WebSocket with a DevReloadStrategy interface. getPayload() now accepts an optional devReloadStrategy parameter. The default fallback preserves the current Next.js HMR behavior. Framework adapters can provide their own strategy (e.g., Vite HMR for TanStack Start).
Replace ReadonlyRequestCookies from next/dist with CookieStore from the framework adapter contract in getRequestLanguage.ts. packages/payload now has zero imports from next/ or @next/.
Introduce PAYLOAD_FRAMEWORK env variable to control which framework adapter the dev server starts with. Extract Next.js-specific startup into test/adapters/nextDevServer.ts. The dev.ts script dispatches to the appropriate adapter based on PAYLOAD_FRAMEWORK (defaults to 'next'). This enables future adapters (e.g., tanstack-start) to add their own dev server module and be selected via PAYLOAD_FRAMEWORK=tanstack-start.
Thread a `renderComponent: ComponentRenderer` parameter through the entire form state and table state pipelines instead of hardcoding `RenderServerComponent` imports. Files modified: - renderField.tsx: accepts renderComponent param instead of importing directly - buildColumnState/index.tsx, renderCell.tsx: accept renderComponent param - renderTable.tsx, renderFilters: accept renderComponent param - buildFormState.ts, buildTableState.ts: pass RenderServerComponent as default - iterateFields.ts, addFieldStatePromise.ts: thread renderComponent through - fieldSchemasToFormState/index.tsx: accept and forward renderComponent - renderFieldServerFn.ts: pass RenderServerComponent explicitly - richtext-lexical rscEntry.tsx, buildInitialState.ts: thread renderComponent Non-RSC adapters can now pass RenderClientComponent instead.
Move framework-agnostic Nav, DocumentHeader, and Logo elements from packages/next to packages/ui. Replace next/navigation hooks with RouterAdapter hooks. Replace @payloadcms/ui barrel imports with direct source imports. Leave re-exports in packages/next for backward compatibility.
Move the Default template (Wrapper, NavHamburger) from packages/next to packages/ui. Replace @payloadcms/ui barrel imports with direct source imports. Leave re-exports in packages/next.
Move the following view helpers from packages/next to packages/ui: - Version/RenderFieldsToDiff (entire directory, 22+ files) - Version/fetchVersions.ts, VersionPillLabel/ - Versions/buildColumns.tsx, cells/, types.ts - Dashboard/ (entire tree, 18+ files) - Document/ helpers (getDocumentData, getDocumentPermissions, etc.) - List/ helpers (handleGroupBy, renderListViewSlots, etc.) All @payloadcms/ui imports converted to relative paths. Re-exports left in packages/next for backward compatibility.
Remove outdated TODO comments in PerPage and Autosave components that referenced next/navigation abstraction - these components already use RouterAdapter or don't need navigation hooks at all.
Move the following auth-related view components to packages/ui: - Login/LoginForm, Login/LoginField, Login styles - ForgotPassword (full view + ForgotPasswordForm) - ResetPassword (full view + ResetPasswordForm) - CreateFirstUser (full view + client component) - Verify (full view + client component) - Logout (full view + LogoutClient) - Unauthorized (full view) All next/navigation imports switched to RouterAdapter. All @payloadcms/ui barrel imports converted to relative paths. Re-exports left in packages/next for backward compatibility. Login entry point stays in packages/next (uses redirect()).
Move APIView, APIViewClient, RenderJSON, LocaleSelector and styles from packages/next to packages/ui. Switch useSearchParams from next/navigation to RouterAdapter. Convert @payloadcms/ui barrel imports to direct relative paths. Re-exports left in packages/next.
Move AccountClient, Settings, LanguageSelector, ToggleTheme, and ResetPreferences from packages/next to packages/ui. Account entry point stays in packages/next (uses notFound()). All @payloadcms/ui barrel imports converted to relative paths.
Move DefaultVersionView, Restore, SelectComparison, SelectLocales, VersionDrawer, VersionDrawerCreatedAtCell, SelectedLocalesContext, SetStepNav, and VersionsViewClient to packages/ui. All next/navigation imports switched to RouterAdapter. All @payloadcms/ui barrel imports converted to relative paths. Re-exports created in packages/next for backward compatibility. Also fixes missing B3 re-exports for VersionPillLabel, Versions buildColumns/cells, and RenderFieldsToDiff.
Move NotFoundClient and styles to packages/ui. The NotFoundPage entry point stays in packages/next (uses initReq, Metadata). Re-export created in packages/next for backward compatibility.
7ef54a3 to
b741714
Compare
…on on tanstack
RSC component configs (Select/Radio CustomJSXLabel, ConditionalLogic
CustomServerField, Tabs UIField) are guarded with isRSCEnabled() so
they are excluded when PAYLOAD_FRAMEWORK_RSC_ENABLED=false.
Tests that depend on custom field components (Label, Error,
BeforeInput/AfterInput, custom Field, RowLabel, CollapsibleLabel,
BlockLabel) or on RSC-specific network request patterns
(assertNetworkRequests matching admin URLs) are annotated with
{ framework: 'rsc' } so they are skipped on non-RSC frameworks
like TanStack Start, where the server-side import map is
intentionally empty and rendered React elements are stripped
during serialization.
The PAYLOAD_FRAMEWORK_RSC_ENABLED env var is set inside the dev
server process, not in the Playwright runner process. Fall back
to inferring RSC support from PAYLOAD_FRAMEWORK so that
{ framework: 'rsc' } test annotations work correctly.
This test relies on custom RSC components (beforeInput/afterInput) that cannot be resolved on TanStack due to the empty server-side import map.
Add waitForFormReady before tabbing through elements to ensure TanStack's async hydration completes before focus indicator checks.
Two issues prevented document drawers from loading on TanStack Start: 1. toSerializable had no circular reference protection — the server function response contained circular DB schema references that caused JSON.stringify to throw. Added an ancestors WeakSet to detect and break cycles during serialization. 2. DrawerContent expected result.Document (a React node from RSC flight) which is unavailable on TanStack. The data-only handler returns documentViewData instead. DrawerContent now detects the data-only response and builds the document view client-side using DocumentInfoProvider + DefaultEditView.
- Radio: adjust expected a11y violation count (JSX label excluded on TanStack) - Tabs: add waitForFormReady before switchTab to prevent hydration race - Blocks: mark drawer-dependent block row label test as RSC-only - JSON: mark custom AfterField component test as RSC-only - Upload/UploadPoly: use custom test wrapper for framework annotations - toggleDocDrawer: replace fixed waits with waitForFormReady
|
@tannerlinsley I removed all the references to vinxi! thank you for looking at this so much! 🧪 E2E Test Results — TanStack Start Adapter (Updated)Run: 24477822883 · 2026-04-15 Suite-level pass rates
Individual test-level pass rates (estimated)
Progress vs April 10 run
|
… and Node.js bundle leakage - RouterAdapter: strip origin from absolute URLs before router.navigate (fixes auth unlock-on-logout test) - processMultipart: reject promise gracefully on busboy errors to prevent server crash on oversized uploads - safeFetch/getDependencies/envPaths: lazy-init Node.js API calls to prevent leakage into browser bundles - renderCell: import MissingEditorProp/APIError from payload/shared to break server-only import chain - exports/shared: export APIError and MissingEditorProp for client-safe access - Form: memoize fieldsReducer tuple to prevent spurious context re-renders (infinite loop with Lexical) - RenderField: use stable primitive selector and resolve custom field components from client import map - renderField: populate clientFieldComponentPath in field state for non-RSC adapters - Auth view: guard auth fields with useFormInitializing to avoid premature enable during hydration - usePreventLeave: clear cancelledURL before pushing to avoid double-navigation - AdminView: resolve custom LivePreview component client-side from import map; add trash view rendering - Root/getRouteData: add trash viewType handling; fetch list data with trash:true for trash views - live-preview routes: add /live-preview and /live-preview/$ routes with server functions and LivePreviewPage component - auth test: handle TanStack Start server function POST URL pattern for lockDoc assertion - auth/CreateFirstUser: support non-RSC render mode for TanStack Start - fields/Indexed e2e: use gotoAndWaitForForm to wait for form hydration before filling - gitignore: ignore tanstack-app/media uploads directory
…tart Strip serverProps from clientFieldComponentPath in renderField to avoid RSC serialization errors when functions are passed via serverProps. Add resolve.dedupe for react, react-dom, and @payloadcms/ui in the tanstack-start vite config to prevent duplicate React context instances.
…iew, query-presets, duplicate dashboard - Fix Lexical editor interactive UI (slash menu, toolbars, node types) not rendering in TanStack by passing clientFieldComponentProps (features, featureClientSchemaMap) from form state to the resolved richText client component. Skip richText fields in the generic import-map resolution path so they reach the dedicated case with full props. - Fix duplicate .dashboard class in TanStack by removing templateClassName from DefaultTemplate (already applied via the view content). - Fix live-preview custom component rendering by passing livePreviewComponent path from server config through SerializableDocumentViewData instead of reading it from the stripped client-side entity config. - Use framework-agnostic @payloadcms/ui imports for query-presets, folders, and slug field components instead of @payloadcms/next/client, enabling these features in non-Next.js adapters.
…ontent serialization Extract duplicated ListViewData→SerializableListViewData conversion into a shared `toSerializableListViewData` utility, used by both the TanStack Root page loader and the data-only render-list handler. This eliminates ~280 lines of workarounds from ListDrawer/DrawerContent.tsx (hardcoded QueryPresetDrawerList, normalizeQueryPresetCollectionConfig, and inline serialization) by having the server return fully serializable data with a `collectionConfigOverride` for hidden collections like payload-query-presets. Additional fixes included in this changeset: - TanStack RouterAdapter: handle search-only URL replacements correctly - DocumentDrawer: prevent redirect-on-create for drawer-based operations - QueryPresetBar: call router.refresh() after preset title-only saves - sanitizeQuery: preserve explicit empty overrides when a preset is active - TableColumns: sync optimistic state with server-provided column state - handleGroupBy: return per-group PaginatedDocs for client-side rendering - buildListViewClientProps: support grouped table reconstruction - transformColumnPreferences: harden JSON parsing for bare string values
Keep TanStack list reconstruction from dropping rich text and blocks field metadata, and stabilize router navigation handlers so route changes do not recreate the adapter context on every navigation.
Capture the remaining TanStack admin view, generated import map, and payload type updates so the branch stays aligned with the current serialized admin page shape.
Avoid react-dom/server in TanStack version diff field renderers so the tanstack-app production build stays on the client-safe side of import protection.
…in object
The SerializableVersionViewData type stored clientSchemaMap as a Map, but
TanStack Start's server→client data transfer serializes via JSON, which
turns Maps into `{}`. This caused VersionDiffViewContent to crash when
buildVersionFields iterated an empty Map, taking down all 40 tests in
the versions diff-view shard (3/3).
- Change SerializableVersionViewData.clientSchemaMap to Record<string, unknown>
- Convert Map→object on the server side (Root/index.tsx)
- Reconstruct Map on the client side in VersionDiffViewContent (AdminView.tsx)
with a guard that still accepts Map instances for backward-compat
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
toSerializableListViewData emits a lossy collectionConfigOverride.fields
array (only name/type/label/admin-subset are kept), intended as a fallback
for callers with no client config access. buildListViewClientProps was
using those stripped fields to OVERRIDE the full baseClientCollectionConfig
fields, losing hasMany, required, localized, maxLength, etc.
During TanStack Start SSR, this crashed TextFieldComponent and other
field renderers that destructure those properties:
TypeError: Cannot destructure property 'admin' of '{}' as it is undefined
at TextFieldComponent (packages/ui/src/fields/Text/index.tsx:24)
Flip the priority so the base client config fields are used when present,
falling back to the serializable override only when no base exists.
Also harden the payloadProxy collection-config fallback with admin defaults
(components/hidden/useAsTitle) so downstream destructuring is safe.
Fixes regressions in locked-documents plus field suites (Date, Radio,
Tabs, Text) on TanStack.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
dataloader is listed in optimizeDeps.include as 'payload > dataloader', so Vite pre-bundles it into .vite/deps. But the browser was still pulling in the raw CJS /dataloader/index.js (via payload's internal loader) and crashing with: SyntaxError: The requested module '.../dataloader/index.js' does not provide an export named 'default' Add a load() hook transform — mirroring the existing deepmerge/pluralize shims — that rewrites the raw CJS import to re-export the pre-bundled default. Fixes the access-control TanStack suite (0/2 → functional). Also drops the capturing group in the ssr-empty-style regex that was failing the lint job (regexp/no-unused-capturing-group) and blocking CI on the lint step. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The multi-tenant plugin registers GlobalViewRedirect in admin.components.actions. The component is an async server helper that calls next/headers and next/navigation — in Next.js RSC this works; in TanStack Start it gets rendered as a React component and crashes: Error: %s is an async Client Component. Only Server Components can be async. <GlobalViewRedirect> All 31 tests in the plugin-multi-tenant TanStack suite failed because the admin view crashed before rendering. Filter out action paths that target next-only server helpers in getRouteData so the admin view renders cleanly. The tenant-redirect behavior itself is a separate feature parity gap (the plugin needs a TanStack-native redirect path). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The admin-bar test navigates to /admin-bar and expects the #payload-admin-bar element to render. The Next.js test setup has a dedicated page at test/admin-bar/app/admin-bar/page.tsx — the shared TanStack app had no equivalent, so the whole suite (1/1) failed. Add a TanStack file route that mounts @payloadcms/admin-bar's PayloadAdminBar component, deriving cmsURL from window.location.origin so it talks to the same dev server that serves /admin and /api. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Run #4 shipped a dataloader load() hook, but the access-control TanStack suite stayed 0/2 with the same SyntaxError: ...dataloader/index.js does not provide an export named 'default' because nothing intercepted the original import. Two issues were stacked: 1. In CI, payload is installed from packed tarballs into test/node_modules. When payload/dist/collections/dataloader.js does `import 'dataloader'`, Vite resolves it via test/node_modules/.pnpm/dataloader@2.2.3/... — a different physical path than the pre-bundled entry (which resolved via the workspace packages/payload → root node_modules/.pnpm/...). Vite's resolver sees the mismatch and serves the raw CJS instead of the pre-bundled ESM wrapper. 2. Vite then appends ?v=<hash> to the served URL, so the existing load() hook's `id.endsWith('/index.js')` check never fires against the queried path. Mirror the deepmerge/pluralize/ajv pattern: add a transform() hook that rewrites `from 'dataloader'` inside payload/dist/collections/dataloader.js directly to /node_modules/.vite/deps/payload___dataloader.js. That is the primary fix and bypasses any resolution ambiguity. Also make the load() hook query-safe (strip ?v=<hash> before the endsWith check) as a defensive fallback for any other importer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…otstrap Run #4's Map→object fix for versionViewData.clientSchemaMap landed but the versions diff-view shard stayed 0/40. The actual blocker was a separate non-serializable value in the payload — RegExp literals from rich-text feature markdownTransformers (e.g. UPLOAD_PLACEHOLDER_REGEX, /!\[([^\]:]+):([^\]]+)\]\(\)/). toSerializable previously preserved RegExp unchanged. seroval then serializes the regex into the SSR bootstrap script, but with doubled backslashes in source (emits `/!\\[.../` instead of `/!\[.../`). That is syntactically invalid JS regex ("Unmatched ')'"), so the script that populates `window.$_TSR` throws mid-stream. TanStack hydration then fails with "Invariant: Expected to find bootstrap data on window.$_TSR", the client re-renders the tree empty, and the diff wrapper never reaches the DOM — hence every test in `versions/e2e.spec.ts › Versions diff view` timed out waiting for `.render-field-diffs`. Next.js doesn't hit this because its server→client transport is JSON, which silently drops RegExp. Strip RegExp here to match that behavior. Rich-text transformer regexes are server-only (markdown import/export) and aren't referenced by buildVersionFields or any client renderer, so the loss is harmless. Verified locally against test/versions/config.ts: before, the page stuck at the Invariant error with an empty client tree; after, the bootstrap parses, hydration completes, and .render-field-diffs renders through SSR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🧪 E2E Test Results — TanStack Start Adapter (Updated — April 21)Run: 24743389080 · 2026-04-21 Suite-level pass rates
Individual test-level pass rates
Progress vs April 15 run
|
This is an experiment for now
Framework Adapter Pattern + TanStack Start Adapter
Decouples Payload's admin panel from Next.js, making it renderable on any SSR framework. Ships
@payloadcms/tanstack-startas the first non-Next adapter — a proof that the abstraction works.Core Idea
packages/uibecomes framework-agnostic. Framework-specific concerns (routing, request handling, server functions, HMR) are pushed behind typed contracts inpackages/payload. Each framework implements its own adapter package.Two modes of rendering:
'use server'actions returning JSXcreateServerFnreturning JSONnext/headers@tanstack/react-start/servervite:beforeFullReloadDependency Graph
graph TD payload["payload<br/><i>adapter contracts (types only)</i>"] ui["@payloadcms/ui<br/><i>framework-agnostic components + data fetchers</i>"] next["@payloadcms/next<br/><i>RSC · server actions · next/headers</i>"] tanstack["@payloadcms/tanstack-start<br/><i>SSR · createServerFn · @tanstack/react-start</i>"] app_next["Next.js App"] app_tanstack["TanStack Start App"] ui -- "peer" --> payload next -- "peer" --> payload next --> ui tanstack -- "peer" --> payload tanstack --> ui app_next --> next app_tanstack --> tanstackWhat Changed
packages/payload— adapter contract types:RouterAdapterComponent,ServerAdapter,ComponentRenderer,DevReloadStrategy,ServerFunctionMode('rsc'|'data-only').packages/ui— zeronext/*imports. Shared server function registry, data-only handlers,RenderClientComponent, injectableRootProviderprops (router, server function, reload strategy). View data fetchers extracted (getRootViewData,getListViewData,getDocumentViewData, etc.).packages/next— refactored to use extracted data fetchers and re-exports fromui. Unchanged runtime behavior.packages/tanstack-start— new package: router adapter, server adapter (initReqvia@tanstack/react-start/server),handleServerFunctions(data-only mode), Vite HMR strategy, admin views, auth helpers (login/logout/refreshviacreateServerFn).tanstack-app/— working example app: TanStack Start + TanStack Router file routes, Vite config, import map. The build stack is purely Vite +@tanstack/react-start/plugin/vite(which uses H3 under the hood for the server layer).