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.
…framework-adapter-pattern
…g, and DnD flakiness - Gate richText CustomField bypass on importMap availability so Next.js (where ImportMapProvider is not mounted) continues using the RSC-rendered field while TanStack uses the client import map path - Remove unnecessary type assertion in tanstack-app importMap.server.ts - Add wait after initPayloadInt for MongoDB index creation in localizeStatus integration tests - Replace synchronous bgColor check with auto-retrying toHaveCSS assertion in dashboard DnD test
…2E flakiness - Fix TanStack Start scheduler runtime crash (61 test failures): bump scheduler from ^0.25.0 to ^0.27.0 to match react-dom@19.2.4, add scheduler to optimizeDeps.include and resolve.dedupe in Vite plugin - Fix NotFoundPage missing req prop passed to DefaultTemplate, causing TypeError in getNavData - Remove unreliable DnD isOver color assertion in dashboard E2E test - Add toBeVisible() wait before focus() in LexicalJSXConverter and LexicalListsFeature E2E beforeEach hooks to prevent timeouts
… test regressions Store serializable `clientComponentPaths` in form state alongside RSC-rendered `customComponents`, enabling non-RSC adapters (TanStack Start) to resolve Label, Error, BeforeInput, AfterInput, Description, and RowLabel components from the import map on the client when the React elements are stripped during JSON serialization. - Add `clientComponentPaths` to FieldState and Row types - Store PayloadComponent path references in renderField.tsx - Resolve paths via import map in useField hook when customComponents missing - Wrap import-map custom Field in WatchCondition (fixes conditional logic) - Resolve Array RowLabel from clientComponentPaths via import map - Mark inline JSX option label tests as Next-only (not serializable) - Add toBeVisible() checks before focus() in Lexical E2E tests - Add ssr.noExternal for plugin/storage packages in Vite config - Add server.warmup and adjust CI retries/maxFailures for TanStack
…formatting for TanStack adapter - Add duck-typing fallback in formatErrors.ts to handle instanceof failures caused by Vite bundling duplicate module instances - Pass path and clientProps to import-map resolved components in useField and ArrayRow - Pass clientField prop to custom field components resolved from import map in RenderField - Fix useField treating empty serialized customComponents object as valid, preventing clientComponentPaths resolution - Rewrite ConditionalLogic E2E test to assert UI behavior instead of framework-specific network requests
…ering - Add client-side import map resolution for custom block row labels in BlockRow, mirroring the existing ArrayRow pattern. When server-rendered Label is unavailable (e.g. TanStack serialization), resolves the component from clientComponentPaths via getFromImportMap. - Make assertNetworkRequests framework-aware to also match TanStack's /api/server-function endpoint. - Fix reorderBlocks test helper: use center-based coordinates for dnd-kit's closestCenter collision detection, wait for drag handles to be visible and layout to stabilize before reading bounding boxes, and throw on missing handles instead of silently returning.
- Remove BrowseByFolderButton import and folder config from Nav client - Replace admin.components.elements with admin.components.edit for globals - Remove getFolderResultsComponentAndData (deleted in hierarchy refactor) - Remove hideAPIURL checks (removed in favor of view conditions) - Clean up package.json export for deleted module
Description lives on SharedAdminComponents directly, not under .edit
- tanstack-start: replace folders/browseByFolder with hierarchy pattern - tanstack-start: remove browseByFolderSlugs from route data and view props - next: fix CollectionCards re-export to use local widget path - next: add missing getCustomGlobalViewByRoute import
The scss file was removed by the incoming branch but is still needed by DefaultNav which references nav CSS classes.
Hierarchy components in @payloadcms/ui were importing useRouter and useSearchParams from next/navigation, breaking the tanstack-app build due to import-protection blocking *.server.* files from next internals. Switched to the framework-agnostic RouterAdapter provider.
… compatibility DrawerContent passed undefined redirectAfterDelete/Duplicate/Restore to DocumentInfoProvider, causing DuplicateDocument and DeleteDocument to use their default of true—redirecting the page instead of staying in the drawer. Default all three to false so in-drawer actions correctly invoke onDuplicate/onDelete callbacks and update the parent relationship field. Also fixes tanstack-start E2E issues: - Combine separate type and value imports from 'payload' in test configs to avoid Rolldown duplicate-declaration parse errors - Refactor tanstack-app API route to use static import of handleEndpoints via a helper module, fixing "handleEndpoints is not a function" - Update addListFilter helper to fall back to URL-based waiting when server functions are used instead of REST API calls
Server functions were returning `_redirect` data causing client-side redirect handling which led to "Not Found" errors on unauthenticated page loads. Using `throw redirect()` ensures redirects happen at the SSR level before any client rendering.
RSC components (from @payloadcms/richtext-lexical/rsc, @payloadcms/next/rsc, etc.) should never be bundled for the client. When importMap.js is loaded in the client environment, these RSC imports pull in server-only dependencies (ajv, url, path, pino-pretty) causing SyntaxErrors and hydration mismatches. Add a Vite plugin that transforms importMap.js in the client environment to: - Comment out RSC import lines - Replace corresponding map entries with null This eliminates the ajv "no default export" SyntaxError in CI and the hydration mismatch between server and client rendering of nav components.
…tion to route loader throw redirect() inside createServerFn doesn't work during SSR - TanStack Start can't properly serialize/handle the redirect response during server-side rendering. This caused every fresh page navigation to show "Not Found" instead of the admin panel, breaking all Lexical E2E tests that use Playwright's per-test page fixture. The fix returns redirect/notFound as data from the server function and handles them in the route loader, where TanStack Router's SSR pipeline processes them correctly.
…wser The payload barrel export transitively imports ajv (via fields/validations.ts) which is a CommonJS module. When the browser tries to load it as native ESM, it throws SyntaxError: "does not provide an export named 'default'". This adds a Vite plugin that intercepts ajv imports in the client environment and provides an empty ESM shim, since ajv is only needed server-side for JSON field validation. Also fixes query parameter handling in stripRscFromClientImportMap so it correctly matches importMap.js even with Vite's ?t= cache-busting params.
…loading Replace the individual ajv shim and RSC import map stripping with a generic CJS-to-ESM wrapping plugin. When packages in optimizeDeps.exclude (like payload) import CJS dependencies, Vite serves them raw via /@fs/ URLs, causing SyntaxErrors in the browser. The new wrapCjsForClient plugin detects CJS patterns (module.exports, exports.X) in node_modules files and wraps them with a CommonJS-like runtime shim that exports ESM. This fixes all lexical E2E test failures caused by ajv, deepmerge, and other CJS packages being loaded as native ESM in the browser.
The wrapCjsForClient plugin should only run during `vite serve` (dev). In production builds, Rolldown/Rollup already handles CJS-to-ESM conversion. Running the wrapper during build caused 281 parse errors from duplicate identifier declarations.
The upstream merge removed supportsServerFastRefreshConfig and supportsTurbopackExternalizeTransitiveDependencies since the minimum Next.js version is now 16.2.6. Keep only PAYLOAD_FRAMEWORK_RSC_ENABLED.
The Lexical editor was rendering with contenteditable="false" in TanStack Start because the initial form state has initializing=true (isMounted starts false), causing the field to pass readOnly=true to LexicalProvider on first render. The useMemo for initialConfig intentionally excluded readOnly from deps, so the stale editable:false was never recalculated. Fix: - LexicalProvider: use hasBeenEditable ref to track the first editable transition and only remount LexicalComposer once (not on every save cycle) - LexicalEditor: add EditorReadyPlugin that sets data-lexical-editable on root - RscEntryLexicalCell: guard against undefined payload/i18n when rendered client-side in TanStack Start list drawers - Test helpers: add waitForLexicalReady and update navigateToLexicalFields to wait for contenteditable="true" before interacting with editors - Skip 2 tests on TanStack Start (focus restoration after save, RSC custom Cell in list view) that require framework-level fixes Also includes prior work: TanStack RSC CollectionCards widget, Vite plugin client export resolution, importMap.server.ts removal.
…ronment Ajv's runtime modules attempt to mutate ESM namespace objects returned by Vite's CJS-to-ESM interop, causing "Cannot add property code, object is not extensible" during SSR. The fix adds a post-transform plugin that creates mutable shallow copies before the mutation occurs. Also stubs server-only RSC imports on the client, redirects bare 'payload' imports to 'payload/shared' to avoid server-side top-level side effects, and moves MissingEditorProp imports to payload/shared in UI components.
…framework-adapter-pattern # Conflicts: # packages/next/src/views/List/index.tsx
- Remove enableListViewSelectAPI conditional (feature is now always on) - Add renderComponent: RenderServerComponent to handleGroupBy/renderTable calls in next package (required by our branch's ComponentRenderer API) - Add renderComponent to renderFilters call
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).