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.
…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
|
Move all shared Vite plugins, optimizeDeps, SSR externals, and aliases into a payloadPlugin() function exported from @payloadcms/tanstack-start/vite. Reduces consumer vite.config.ts from ~690 lines to ~10 lines. Shim files are now bundled within the package. Adds ssr.noExternal for @payloadcms/* to fix SCSS resolution in published (non-workspace) installs.
…ayout route Split __root.tsx into a thin HTML shell and a _payload.tsx pathless layout route. Admin and API routes now live under _payload/ and are the only routes that load Payload styles, RootProvider, and server data. Frontend routes (index, admin-bar, live-preview) bypass the Payload layout entirely.
…ponents The TanStack Start adapter was not wiring admin.components.beforeDashboard and afterDashboard from the Payload config to the DefaultDashboard view. - Add beforeDashboard/afterDashboard to SerializableDashboardData - Populate them from config.admin.components in the Root page loader - Resolve component paths via RenderClientComponent using the import map and pass rendered nodes to DefaultDashboard
…eware installation Move tanstackStart and viteReact imports from payloadPlugin internals to consumer-provided parameters, ensuring all Vite plugins resolve from the same node_modules tree. Without this, pnpm's strict hoisting caused @tanstack/start-plugin-core to import a different vite instance than the dev server, making isRunnableDevEnvironment() fail silently and skipping TanStack Start's SSR middleware. Also adds --configLoader runner to handle .ts module resolution under Node.js v24's strict ESM loader.
…framework-adapter-pattern # Conflicts: # packages/richtext-slate/src/index.tsx # pnpm-lock.yaml # test/admin/collections/Posts.ts # test/lexical/payload-types.ts
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).