Skip to content

feat: tanstack framework adapter#16139

Draft
r1tsuu wants to merge 228 commits into
mainfrom
experiment/framework-adapter-pattern
Draft

feat: tanstack framework adapter#16139
r1tsuu wants to merge 228 commits into
mainfrom
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 r1tsuu reopened this May 15, 2026
@r1tsuu r1tsuu force-pushed the experiment/framework-adapter-pattern branch from bfc2491 to 8fd93c0 Compare May 15, 2026 17:09
r1tsuu added 27 commits May 15, 2026 18:11
…adapter-pattern

# Conflicts:
#	packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx
…es eagerly

With `router: { autoCodeSplitting: true }` TanStack Router fetches each
route's `component` lazily via `?tsr-split=component` after the initial
SSR HTML is streamed. Until that lazy chunk lands the rendered admin
tree has no client React attached, so any button click (e.g. the
`#toggle-list-filters` chevron in `<ListControls />`) hits a static DOM,
the click is dropped, and once the chunk finally loads the component
re-mounts with its initial `useState` instead of the toggled value.

That single behaviour was the root cause of every "page renders but
nothing is interactive" tanstack-start E2E failure I traced - the
`#list-controls-where` filter never opened, `[data-lexical-editor]`
never rendered after a navigation, and lock-state didn't propagate to
the lexical editor for the same reason.

Trade-off: a slightly larger initial client bundle, in exchange for
the admin actually being interactive on first paint. That's the
contract every Payload admin component is written against.
…adapter-pattern

# Conflicts:
#	packages/ui/src/elements/Card/index.scss
#	packages/ui/src/widgets/CollectionCards/index.scss
#	packages/ui/src/widgets/CollectionCards/index.tsx
Stylesheets were renamed during the merge from origin/main but the
client-side variant of CollectionCards was still importing the deleted
.scss file, which broke the tanstack-app rolldown build.
Resolved conflicts caused by two main-side changes:

1. SCSS-to-CSS conversion of Default template + ModularDashboard. Our
   branch had moved both modules from `packages/next` to `packages/ui`,
   so reapplied the same .scss -> .css swap inside the new home and
   copied over main's css contents.

2. AppHeader reskin (#16619) trimmed `CustomIcon` from `<AppHeader>`'s
   Props. Drop the now-unused prop from `DefaultTemplate` so ui builds
   pass.

For the Nav / Default / ModularDashboard tsx conflicts kept our branch's
delegation pattern - the next-side wrappers re-export the rebuilt UI
versions from `@payloadcms/ui`, which already pick up main's behaviour
through the dependency graph.
Disabling autoCodeSplitting eagerly bundles route components, which
pulls user-supplied custom components (e.g.
`test/access-control/CustomDashboard.tsx` re-exporting
`@payloadcms/next/views`) into the browser bundle. That transitively
imports `@payloadcms/ui/assets`, whose `./payload-favicon.svg`
default-export crashes at runtime in the browser:

  Error: The requested module './payload-favicon.svg' does not provide an export named 'default'

That single regression failed every tanstack-start E2E (105 jobs, all
red), so back the previous setting out while we look for a less
invasive fix for the click-not-attached issue.
Our Node ESM loader silenced \`ERR_UNKNOWN_FILE_EXTENSION\` for asset
imports during SSR, but it returned a literally empty module. That made
\`import logo from './payload-favicon.svg'\` blow up immediately with:

  SyntaxError: The requested module './payload-favicon.svg'
  does not provide an export named 'default'

Which surfaced inside getLayoutDataFn, killed the SSR tree, and
re-exploded in the browser console - that single regression was the
root cause of every \"Browser console error: ... payload-favicon.svg\"
tanstack-start E2E failure (60+ specs across access-control, fields,
lexical, locked-documents, etc.).

The stub now exposes the specifier as both default and a Next-style
\`StaticImageData\` shape (\`src\`, \`width\`, \`height\`, blur fields)
so any consumer that does
  import logo from './logo.png'
or
  import { src } from './photo.jpg'
sees a value SSR can render to HTML without crashing. Stylesheets
still resolve to an empty module (matches Vite's extracted-css
behaviour).
Two complementary fixes for the remaining tanstack-start E2E failures:

1. Disable TanStack Router's autoCodeSplitting again, now that the
   asset-import regression that originally forced it back on (favicon
   default-export crash) is fixed by the cssLoader stub. Playwright
   traces for the JSON / lexical / locked-document tests show the
   "Download the React DevTools" message landing *after* the click, i.e.
   React hydrated only once the lazy `?tsr-split=component` chunk
   arrived, and the click that flipped \`visibleDrawer\` to \`'where'\`
   landed on a static SSR DOM with no handlers attached. Eager-loading
   the route component fixes every "page renders but nothing is
   interactive" symptom (#list-controls-where, [data-lexical-editor],
   etc.).

2. Add server-only stubs for \`docAccessOperation\` /
   \`docAccessOperationGlobal\` to \`payload/shared.ts\`. Server-only
   utilities in \`@payloadcms/ui\` (e.g. \`getDocumentPermissions\`)
   import them from \`payload\`, the browser condition resolves to
   \`payload/shared\`, and without those names the dev server crashed
   the moment a client chunk that transitively pulled
   \`getDocumentPermissions\` hit the browser. Pulling the real
   implementation in would drag the whole server graph (operation
   pipelines, transactions, etc.) into the client bundle, so we expose
   throw-on-call stubs - if anything ever does invoke them client-side
   we want a loud, traceable failure rather than a silent \`undefined\`.
…ttingOptions

The previous attempt to disable TanStack Router's `?tsr-split=component` lazy
chunks via `router.autoCodeSplitting: false` was a no-op: `tanstackStart`'s
schema does `tsrConfig = configSchema.omit({ autoCodeSplitting: true, target:
true })`, so the user-supplied flag is silently dropped before it ever reaches
`getConfig`. The TanStack Start vite plugin then unconditionally installs the
router code-splitter regardless, so every route component kept getting fetched
lazily after the initial SSR HTML.

Trace from CI confirms the symptom on `fields/JSON > WhereBuilder`:

  116814 ms  goto  /admin/collections/json-fields
  118304 ms  click #toggle-list-filters    <-- happens here
  118785 ms  console.info "Download the React DevTools..."

The "React DevTools" banner is React's first client-side log, so the click
landed ~480 ms before React was even alive. After hydration the `?tsr-split=
component` chunk replaces the route's `component` reference, which forces a
remount and resets the `useState` that the click was supposed to flip - the
filter drawer therefore never opens and the test times out on
`#list-controls-where.rah-static--height-auto`.

The only knob the start plugin actually honours is
`router.codeSplittingOptions.defaultBehavior`. Setting it to `[]` makes the
splitter walk each `createFileRoute(...)` file but emit no virtual `?tsr-
split=...` modules, so the route component ships in the initial bundle and
hydration starts on first paint. We keep `autoCodeSplitting: false` as a
belt-and-braces signal in case start-plugin-core ever stops dropping it.

The smoke test is updated to assert the new shape so we don't silently
regress this again.
…test app

The plugin-import-export E2E suite was failing with:

  [getLayoutDataFn] Error: Cannot find module
  '@payloadcms/plugin-import-export/rsc' imported from
  '/home/runner/work/payload/payload/tanstack-app/src/importMap.js'

The suite's `payload.config.ts` registers the plugin, which causes
`generateImportMap` to write `import {...} from '@payloadcms/plugin-
import-export/rsc'` into `tanstack-app/src/importMap.js`. The Node-side
server function then dynamically imports that file, but `@payloadcms/
plugin-import-export` was not declared as a dependency of the
`tanstack-app` workspace package, so pnpm never linked it under
`tanstack-app/node_modules` and Node ESM resolution fell off the cliff.

`plugin-form-builder`, `plugin-multi-tenant`, etc. were already declared
- this was the only one missing from the list. `plugin-nested-docs` /
`plugin-redirects` are not added because their suites pass without them
(their RSC entries aren't referenced by their generated import maps).
… latency

Network traces from a fresh CI run show the admin page firing ~150
individual \`node_modules/.pnpm/@TanStack+*/.../dist/esm/*.js?v=...\`
requests before the first React hydration tick, e.g.

  /node_modules/.pnpm/@TanStack+react-router@.../dist/esm/awaited.js
  /node_modules/.pnpm/@TanStack+react-router@.../dist/esm/useNavigate.js
  /node_modules/.pnpm/@TanStack+router-core@.../dist/esm/router.js
  ... (~150 more)

Each TanStack package is intentionally distributed as a fine-grained
ESM tree so app bundlers can tree-shake aggressively, but in dev mode
Vite serves every leaf module as its own request. On a cold ubuntu CI
runner this pushes \`React DevTools\` (the first client-side log,
emitted from \`hydrateRoot\`) ~1.5-2s past Playwright's first \`click\`,
and any synthetic event that lands on the static SSR DOM gets dropped.
That single behaviour explains the long tail of "page renders but
nothing is interactive" failures (#list-controls-where, #list-menu,
[data-lexical-editor], etc.) that the recent \`tsr-split\` removal
commit only partially addressed.

Listing the package roots in \`optimizeDeps.include\` makes Vite roll
each TanStack package into a single pre-bundled chunk in
\`node_modules/.vite/deps/\`, so hydration finishes well before the
first interactive assertion. The runtime cost on dev startup is a
single up-front esbuild pass.
The `installTanStackHydrationGotoWait` patch was causing every
`page.goto` in `ensureCompilationIsDone` to wait 15s for a hydration
marker that can never appear while the dev server is still compiling.
With 15 compilation attempts, this burned up to 225s of the 240s
beforeAll hook timeout, causing nearly all TanStack E2E suites to fail
with "beforeAll hook timeout exceeded".

Add a `__payloadSkipHydrationWait` flag that `ensureCompilationIsDone`
sets during its polling loop, so the patched `goto` skips the
`waitForFunction` call during warmup and restores normal behaviour
once compilation succeeds.
1. Add runtime-throwing stubs to payload/exports/shared.ts for
   `executeAuthStrategies`, `getAccessResults`, `getPayload`,
   `getRequestLanguage`, and `docAccessOperationGlobal`. These are
   server-only functions that get resolved via the "browser" export
   condition when Vite's client bundler walks through
   @payloadcms/tanstack-start's entry. Without the stubs, the client
   module crashes with SyntaxError "does not provide an export named".

2. Add a no-op .catch() to the `busboyFinished` promise in
   processMultipart.ts. When abortOnLimit rejects `allFilesComplete`
   (which throws at the await on line ~241) before execution reaches
   `await busboyFinished`, the busboy error becomes an unhandled
   rejection that crashes the TanStack Start server process.
Create a dedicated server barrel export for getDocumentPermissions,
getDocumentViewData, and getCollectionCardsData. This allows TanStack
Start's import protection to block these from the client module graph,
eliminating the need for runtime-throwing stubs in payload/exports/shared.ts.
…n custom components

- Add handleGraphQL server utility mirroring @payloadcms/next behaviour
- Serialize beforeLogin/afterLogin admin components and render them in LoginViewContent
- Allow imports from /payload/dist/ and @payloadcms/ui rsc exports through import protection
- Skip generated importMap and *.server-function.ts files from TanStack Router routesDirectory
- Regenerate importMap and payload-types
The dotenv-based loader from `feat: replace @next/env with dotenv in
packages/payload` (ad1632c) was accidentally reverted to `@next/env`
during conflict resolution in `59bbd8349c merge issues` when merging
main into the branch. Restore the dotenv implementation (matching what
PR #16575 ships) and re-add `dotenv` / `dotenv-expand` while removing
`@next/env` from the payload package's dependencies.
… from layout data

- Add `head.links` to `__root.tsx` to preconnect to Google Fonts and
  load Inter + Roboto Mono, matching what Next.js's RootLayout pulls in
- Move the `payload-default, payload` CSS @layer declaration into the
  root `<head>` so it applies before any admin route mounts
- Mirror `data-theme`, `dir`, and `lang` from layout data onto
  `<html>` from `_payload.tsx` (TanStack owns `<html>` in `__root.tsx`
  but doesn't have layout data; sync from the payload layout instead).
  Lowercase the `dir` value to match HTML spec.
- Regenerate `tanstack-app/src/importMap.js`
@jacobsfletch jacobsfletch changed the title feat: admin framework adapter pattern and tanstack support feat: tanstack framework adapter May 21, 2026
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