Skip to content

feat: admin framework adapter pattern and tanstack support#16139

Draft
r1tsuu wants to merge 122 commits intomainfrom
experiment/framework-adapter-pattern
Draft

feat: admin framework adapter pattern and tanstack support#16139
r1tsuu wants to merge 122 commits intomainfrom
experiment/framework-adapter-pattern

Conversation

@r1tsuu
Copy link
Copy Markdown
Member

@r1tsuu r1tsuu commented Apr 2, 2026

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-start as the first non-Next adapter — a proof that the abstraction works.

Core Idea

packages/ui becomes framework-agnostic. Framework-specific concerns (routing, request handling, server functions, HMR) are pushed behind typed contracts in packages/payload. Each framework implements its own adapter package.

Two modes of rendering:

Next.js TanStack Start
Server rendering RSC (flight payloads) SSR + route loaders
Server functions 'use server' actions returning JSX createServerFn returning JSON
Request init next/headers @tanstack/react-start/server
HMR Next.js built-in Vite vite:beforeFullReload
Build tool Webpack / Turbopack Vite

Dependency 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 --> tanstack
Loading

What Changed

packages/payload — adapter contract types: RouterAdapterComponent, ServerAdapter, ComponentRenderer, DevReloadStrategy, ServerFunctionMode ('rsc' | 'data-only').

packages/ui — zero next/* imports. Shared server function registry, data-only handlers, RenderClientComponent, injectable RootProvider props (router, server function, reload strategy). View data fetchers extracted (getRootViewData, getListViewData, getDocumentViewData, etc.).

packages/next — refactored to use extracted data fetchers and re-exports from ui. Unchanged runtime behavior.

packages/tanstack-start — new package: router adapter, server adapter (initReq via @tanstack/react-start/server), handleServerFunctions (data-only mode), Vite HMR strategy, admin views, auth helpers (login/logout/refresh via createServerFn).

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).

@jmbockhorst
Copy link
Copy Markdown
Contributor

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!

@r1tsuu
Copy link
Copy Markdown
Member Author

r1tsuu commented Apr 3, 2026

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.

r1tsuu added 27 commits April 4, 2026 00:39
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.
@r1tsuu
Copy link
Copy Markdown
Member Author

r1tsuu commented Apr 10, 2026

🧪 E2E Test Results — TanStack Start Adapter

Run: 24249234229 · 2026-04-10
Commit: c9a6ffe


Pass Rates

Suites

Framework ✅ Passing ❌ Failing ⏱ Cancelled Total Rate
TanStack Start 9 67 23 (timeout) 102 (†) 8.8%
Next.js 96 6 0 102 94.1%

(†) 23 TanStack suites were cancelled before finishing due to the 45-minute CI job limit.

Individual Tests

Framework ✅ Passed ❌ Failed ⏭ Skipped Tests ran Rate
TanStack Start 451 476 49 927 48.7%
Next.js 1,561 11 61 1,572 99.3%

TanStack's 23 cancelled suites account for ~566 tests that never ran. Counting those as not-passing: ~29% of all 1,548 tests.


TanStack — Suites With 0% Pass Rate (completely broken)

22 suites ran at least one test and passed nothing:

Suite Tests failed Next.js
fields-relationship 34 ✅ 33 pass
lexical/Lexical__e2e__main 34 ✅ 29 pass
folders 34 (2 pass) ✅ 35 pass
lexical/Lexical__e2e__blocks 29 ✅ 27 pass
lexical/Lexical__e2e__blocks#blockreferences 29 ✅ 27 pass
access-control 2 ✅ 101 pass
i18n 7 ✅ 7 pass
lexical/LexicalLinkFeature 6 ✅ 6 pass
lexical/LexicalFullyFeatured__db 6 ✅ 6 pass
storage-s3__client-uploads 4 ✅ 4 pass
storage-vercel-blob__client-uploads 4 ✅ 4 pass
auth 1 ✅ 2 pass
auth-basic 2 ✅ 2 pass
admin-bar 1 ✅ 1 pass
fields__collections__UI 1 ✅ 1 pass
fields__collections__UploadMultiPoly 2 ✅ 2 pass
fields__collections__UploadPoly 1 ✅ 1 pass
lexical/LexicalHeadingFeature 1 ✅ 1 pass
lexical/LexicalJSXConverter 1 ✅ 1 pass
lexical/LexicalListsFeature 1 ✅ 1 pass
plugin-cloud-storage 1 ✅ 3 pass
server-url 2 ✅ 2 pass
sort 2 ✅ 1 pass

TanStack — Suites Cancelled (45-min timeout, 0 tests ran)

Suite Next.js tests
versions 120
plugin-multi-tenant#conditionalProvider 30
plugin-multi-tenant 28
joins 28
plugin-import-export 38
query-presets 26
localization 46
group-by 38
dashboard 20
admin-root 11
lexical/LexicalFullyFeatured 14
lexical/OnDemandForm 7

TanStack — Partial Results (some tests pass)

33 suites with mixed pass/fail
Suite TanStack Next.js Rate
bulk-edit 1F / 19P 20P 95%
fields__collections__Text 1F / 18P 19P 95%
locked-documents 3F / 42P 45P 93%
field-error-states 1F / 9P 10P 90%
fields__collections__Tabs 1F / 7P 8P 88%
fields__collections__JSON 1F / 6P 8P 86%
fields__collections__Array 4F / 23P 28P 85%
fields__collections__Blocks 6F / 30P 36P 83%
fields__collections__Blocks#blockreferences 6F / 30P 35P 83%
plugin-form-builder 1F / 4P 5P 80%
fields__collections__ConditionalLogic 3F / 11P 14P 79%
fields__collections__Point 1F / 3P 4P 75%
admin__e2e__list-view 18F / 49P 87P 73%
fields__collections__Checkbox 1F / 2P 3P 67%
live-preview 11F / 16P 28P 59%
admin__e2e__document-view 9F / 13P 64P 59%
fields__collections__Email 4F / 6P 9P 60%
fields__collections__Select 2F / 3P 6P 60%
admin__e2e__general 13F / 16P 82P 55%
field-paths 1F / 1P 2P 50%
fields__collections__Radio 3F / 4P 7P 57%
fields__collections__Relationship 11F / 4P (+1 shard cancelled) 33P 27%
fields__collections__Collapsible 2F / 1P 4P 33%
plugin-nested-docs 2F / 1P 3P 33%
plugin-redirects 2F / 1P 3P 33%
a11y 36F / 18P 54P 33%
trash 13F / 7P (+1 shard cancelled) 40P 35%
plugin-seo 4F / 1P 3P 20%
fields__collections__Upload 8F / 1P 9P 11%
lexical/RichText 11F / 1P 9P 8%
uploads 75F / 5P 80P 6%
form-state 16F / 1P 16P 6%
folders 34F / 2P 35P 6%

TanStack — Fully Passing Suites (9)

_community · fields__collections__CustomID · fields__collections__Date · fields__collections__Indexed · fields__collections__Number · fields__collections__Row · fields__collections__Tabs2 · hooks · queues


Next.js — Regressions

6 failing Next.js suites (11 individual tests):

Suite Failed Assessment
auth 2 ⚠️ Real regressionInitialized lexical RSC field without a field name in create-first-user view
admin__e2e__general (shards 1 + 2) 4 Likely pre-existing flake — shard 3 passes
admin__e2e__document-view (shard 1) 2 Likely flake — shards 2 + 3 pass
admin__e2e__list-view (shard 4) 1 Likely flake — shards 1–3 pass
access-control (shard 1) 2 Likely flake — shard 2 passes

Action needed: auth failure (Initialized lexical RSC field without a field name) is a new regression from this PR affecting the create-first-user page with a Lexical richtext field. Must fix before merge.


@tannerlinsley
Copy link
Copy Markdown

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.

r1tsuu added 13 commits April 14, 2026 13:48
- 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.
@r1tsuu r1tsuu force-pushed the experiment/framework-adapter-pattern branch from 7ef54a3 to b741714 Compare April 15, 2026 15:15
r1tsuu added 8 commits April 15, 2026 16:45
…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
@r1tsuu
Copy link
Copy Markdown
Member Author

r1tsuu commented Apr 15, 2026

@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
Commit: d297a1b

Suite-level pass rates

Framework ✅ Passing ❌ Failing ⏱ Cancelled Total Rate
TanStack Start 33 51 22 (timeout) 106 (†) 31.1%
Next.js 100 6 0 106 94.3%

Individual test-level pass rates (estimated)

Framework ✅ Passed ❌ Failed ⏭ Skipped Total ran Rate
TanStack Start ~540 ~410 ~55 ~950 ~57%
Next.js ~1,575 ~9 ~65 ~1,584 ~99.4%

Progress vs April 10 run

April 10 April 15 Δ
TanStack passing suites ~10 / 102 33 / 106 +22
TanStack suite pass rate ~10% 31% +20pp
TanStack test pass rate ~49% ~57% +8pp
Next.js auth Lexical regression ❌ failing fixed

r1tsuu added 4 commits April 17, 2026 16:25
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants