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.
🧪 E2E Test Results — TanStack Start AdapterRun: 24249234229 · 2026-04-10 Pass RatesSuites
Individual Tests
TanStack — Suites With 0% Pass Rate (completely broken)22 suites ran at least one test and passed nothing:
TanStack — Suites Cancelled (45-min timeout, 0 tests ran)
TanStack — Partial Results (some tests pass)33 suites with mixed pass/fail
TanStack — Fully Passing Suites (9)
Next.js — Regressions6 failing Next.js suites (11 individual tests):
|
|
Vinxi is dead. It's just vite + @tanstack/react-start/server (this uses H3 under the hood, but that's irrelevant). If there's anything else you'd like us to review, please come join our discord. I think we have a private payload channel but no one is there yet. |
- Render APIViewClient when documentSubViewType is 'api' instead of falling through to DefaultEditView - Handle viewType 'version' in getAdminPageData to fetch document data for single version views - Add 'version' case to ViewRenderer switch so version pages render the document view content
Auto-generated by TanStack Router on every dev server start, formatting varies between runs causing a perpetual dirty working tree.
…view getCreateFirstUserData was calling buildFormState without a renderComponent, causing it to fall back to RenderClientComponent which does not forward serverProps. The Lexical RSC field entry needs serverProps.clientField to function, so omitting it triggered "Initialized lexical RSC field without a field name" whenever the auth collection contained a richText field. Accept an optional renderComponent parameter in getCreateFirstUserData and thread it through to buildFormState, matching the pattern already used by getDocumentViewData and getAccountViewData. The Next.js caller now passes RenderServerComponent and the TanStack Start caller passes RenderClientComponent.
TanStack Start no longer uses Vinxi. The stack is Vite + @tanstack/react-start (which uses H3 under the hood). Updated planning docs, code comments, and PR description to reflect this.
…framework-adapter-pattern
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.
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).