Skip to content

feat: admin framework adapter pattern and tanstack support#16139

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

feat: admin framework adapter pattern and tanstack support#16139
r1tsuu wants to merge 184 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 added 30 commits May 1, 2026 18:08
…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
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