diff --git a/docs/plans/2026-03-31-admin-adapter-combined.md b/docs/plans/2026-03-31-admin-adapter-combined.md
new file mode 100644
index 00000000000..86fbb88854f
--- /dev/null
+++ b/docs/plans/2026-03-31-admin-adapter-combined.md
@@ -0,0 +1,839 @@
+# AdminAdapter Pattern — Combined Design & Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Decouple Payload CMS from Next.js by introducing an `AdminAdapter` pattern (like `DatabaseAdapter`), enabling frameworks like TanStack Start as alternatives.
+
+**Architecture:** Two-layer abstraction — a `RouterProvider` React context replaces direct `next/navigation` imports in UI, and an `AdminAdapter` interface abstracts server-side concerns (request init, cookies, server functions, navigation primitives like `notFound`/`redirect`). Views move from `packages/next` to `packages/ui`. `packages/next` becomes a thin adapter implementation.
+
+**Tech Stack:** TypeScript, React (RSC), Payload CMS monorepo (pnpm + Turbo)
+
+## Table of Contents
+
+- [Design Decisions](#design-decisions)
+- [RSC Compatibility: Next.js vs TanStack Start](#rsc-compatibility-nextjs-vs-tanstack-start)
+- [Package Responsibilities After Refactor](#package-responsibilities-after-refactor)
+ - [View Split Pattern](#view-split-pattern)
+ - [RSC Component Example: Before & After](#rsc-component-example-before--after)
+- [AdminAdapter Interface](#adminadapter-interface)
+- [RouterProvider Abstraction](#routerprovider-abstraction)
+- [Core Package Decoupling](#core-package-decoupling)
+- [Data Flows](#data-flows)
+- [TanStack Start Adapter Shape](#tanstack-start-adapter-shape)
+- [Implementation Phases](#implementation-phases)
+ - [Phase 1: Define AdminAdapter Interface in Core](#phase-1-define-adminadapter-interface-in-core)
+ - [Phase 2: RouterProvider Abstraction](#phase-2-routerprovider-abstraction)
+ - [Phase 3: Decouple Core Package](#phase-3-decouple-core-package)
+ - [Phase 4: Move Views from packages/next to packages/ui](#phase-4-move-views-from-packagesnext-to-packagesui)
+ - [Phase 5: Refactor packages/next to Implement AdminAdapter](#phase-5-refactor-packagesnext-to-implement-adminadapter)
+ - [Phase 6: CLI and Template Changes](#phase-6-cli-and-template-changes)
+ - [Phase 7: TanStack Start Proof-of-Concept](#phase-7-tanstack-start-proof-of-concept)
+ - [Phase 8: Testing](#phase-8-testing)
+- [Work Items Summary](#work-items-summary)
+- [Dependency Graph](#dependency-graph)
+
+---
+
+## Design Decisions
+
+| Decision | Choice |
+| ----------------- | ---------------------------------------------------------------------- |
+| Approach | RouterProvider + AdminAdapter interface |
+| React/RSC | React-only, RSC-required |
+| Build plugins | Each adapter owns its own |
+| Migration | Non-breaking, auto-detect `@payloadcms/next` |
+| Adapter name | `AdminAdapter` |
+| Views location | Move from `packages/next` to `packages/ui` |
+| Core decoupling | Replace 3 Next.js imports with generic alternatives |
+| UI decoupling | `RouterProvider` context replaces ~38 direct `next/navigation` imports |
+| Server navigation | `notFound()` / `redirect()` abstracted via adapter |
+| Config | `admin.adapter` field, optional (defaults to next) |
+
+---
+
+## RSC Compatibility: Next.js vs TanStack Start
+
+Both frameworks support RSC but with different mechanisms. These differences drive the adapter contract:
+
+| Concern | Next.js | TanStack Start | Adapter Resolution |
+| ---------------- | --------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------- |
+| Server functions | `'use server'` directive | `createServerFn()` from `@tanstack/start` | `adapter.handleServerFunctions` — already abstracted via `ServerFunctionsProvider` |
+| Request context | `headers()` / `cookies()` from `next/headers` | `getWebRequest()` from `vinxi/http` | `adapter.initReq()` — each adapter reads request differently |
+| `notFound()` | Throws special error caught by Next.js | `throw notFound()` from `@tanstack/react-router` | `adapter.notFound()` — added to interface |
+| `redirect(url)` | Throws special error caught by Next.js | `throw redirect({ to: url })` from `@tanstack/react-router` | `adapter.redirect(url)` — added to interface |
+| Caching | `unstable_cache()` / `React.cache()` | Framework-level caching via Vinxi | Caching stays in adapter, not in shared views |
+| OG Images | `ImageResponse` from `next/og` | Custom implementation | Stays in adapter package |
+| Build config | `withPayload()` wrapping `next.config.mjs` | Vite plugin for `app.config.ts` | Each adapter owns its build plugin |
+
+### Framework-Specific Code Audit (packages/next)
+
+| Category | File Count | Must Stay in Adapter |
+| --------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------- |
+| `notFound()` / `redirect()` calls | ~12 views | Yes — views receive these as callbacks or adapter provides them |
+| Client `next/navigation` hooks | 7 client components in packages/next + 38 in packages/ui | No — replaced with `RouterProvider` hooks |
+| `initReq` direct calls | 2 views (Root, NotFound) | Yes — adapter calls initReq, passes result as props |
+| `'use server'` directive | 4 files (auth actions + Root layout) | Yes — server function transport is adapter-specific |
+| `next/headers` (cookies/headers) | 7 utility files | Yes — all go through adapter |
+| `ImageResponse` (OG) | 1 file | Yes |
+
+### Key Constraint
+
+**`packages/ui` must be purely React — zero framework-specific imports.** Every `next/*` call must either:
+
+1. Route through the `AdminAdapter` interface, or
+2. Be passed as props from the adapter's server entry point
+
+---
+
+## Package Responsibilities After Refactor
+
+| Package | Responsibility |
+| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `packages/payload` | Core CMS logic + `AdminAdapter` interface + `createAdminAdapter` helper |
+| `packages/ui` | All React components + views (render only) + layouts + templates + `RouterProvider` context |
+| `packages/next` | `nextAdapter()`: `initReq`, route handlers, `withPayload()`, cookies, auth actions, `notFound`/`redirect`, OG images, `RouterProvider` via `next/navigation` |
+| `packages/tanstack-start` | `tanstackStartAdapter()`: same contract, TanStack-specific implementations |
+
+### View Split Pattern
+
+Views that currently call `initReq` or `notFound()`/`redirect()` are split:
+
+```
+BEFORE (in packages/next):
+ DocumentView.tsx → calls initReq(), calls notFound(), renders UI
+
+AFTER:
+ packages/next: DocumentEntry.tsx → calls initReq(), passes data as props
+ packages/ui: DocumentView.tsx → receives props, calls adapter.notFound() if needed, renders UI
+```
+
+For `notFound()` / `redirect()`: views receive these as functions from a `ServerNavigation` context provided by the adapter, or call `adapter.notFound()` / `adapter.redirect()` through a hook.
+
+### RSC Component Example: Before & After
+
+**CURRENT — tightly coupled to Next.js** (`packages/next/src/views/Document/index.tsx`):
+
+```tsx
+// Imports Next.js-specific APIs directly
+import { notFound, redirect } from 'next/navigation.js'
+
+export const renderDocument = async ({ initPageResult, params, ... }) => {
+ // initPageResult comes from initReq() called in the Root layout (also Next.js-specific)
+ const { collectionConfig, docID, locale, permissions, req } = initPageResult
+
+ // Fetches data — this part is already framework-agnostic
+ const doc = await getDocumentData({ id: docID, collectionSlug, locale, payload: req.payload, req })
+
+ // Next.js-specific: throws special error caught by Next.js error boundary
+ if (isEditing && !doc) {
+ redirect(formatAdminURL({ adminRoute, path: `/collections/${collectionSlug}` }))
+ }
+
+ const [docPreferences, { docPermissions }, { isLocked }] = await Promise.all([
+ getDocPreferences({ id: docID, collectionSlug, req }),
+ getDocumentPermissions({ collectionConfig, id: docID, req }),
+ getIsLocked({ collectionSlug, id: docID, req }),
+ ])
+
+ // Renders UI — this part is framework-agnostic React
+ return {
+ data: doc,
+ Document: (
+
+
+
+
+ ),
+ }
+}
+```
+
+**AFTER — split into adapter entry + framework-agnostic render component:**
+
+`packages/next/src/views/Document/index.tsx` (adapter — stays in packages/next):
+
+```tsx
+// Next.js adapter: thin entry point that handles framework-specific concerns
+import { notFound, redirect } from 'next/navigation.js'
+import { DocumentView } from '@payloadcms/ui/views/Document'
+
+export const renderDocument = async ({ initPageResult, params, ... }) => {
+ const { collectionConfig, docID, locale, permissions, req } = initPageResult
+
+ const doc = await getDocumentData({ id: docID, collectionSlug, locale, payload: req.payload, req })
+
+ // Framework-specific navigation — handled HERE, not in the shared view
+ if (isEditing && !doc && collectionSlug) {
+ redirect(formatAdminURL({ adminRoute, path: `/collections/${collectionSlug}` }))
+ }
+ if (isEditing && !doc) {
+ notFound()
+ }
+
+ const [docPreferences, { docPermissions }, { isLocked }] = await Promise.all([...])
+
+ // Delegates to the framework-agnostic render component
+ return {
+ data: doc,
+ Document: (
+
+ ),
+ }
+}
+```
+
+`packages/tanstack-start/src/views/Document/index.tsx` (adapter — in packages/tanstack-start):
+
+```tsx
+// TanStack Start adapter: same logic, different framework primitives
+import { notFound, redirect } from '@tanstack/react-router'
+import { DocumentView } from '@payloadcms/ui/views/Document'
+
+export const renderDocument = async ({ initPageResult, params, ... }) => {
+ const { collectionConfig, docID, locale, permissions, req } = initPageResult
+
+ const doc = await getDocumentData({ id: docID, collectionSlug, locale, payload: req.payload, req })
+
+ // TanStack Start uses different throw signatures
+ if (isEditing && !doc && collectionSlug) {
+ throw redirect({ to: formatAdminURL({ adminRoute, path: `/collections/${collectionSlug}` }) })
+ }
+ if (isEditing && !doc) {
+ throw notFound()
+ }
+
+ const [docPreferences, { docPermissions }, { isLocked }] = await Promise.all([...])
+
+ // Same shared render component
+ return {
+ data: doc,
+ Document: (
+
+ ),
+ }
+}
+```
+
+`packages/ui/src/views/Document/index.tsx` (shared — pure React, no framework imports):
+
+```tsx
+// Framework-agnostic render component — pure React RSC
+// NO imports from next/*, @tanstack/*, or any framework
+import { DocumentInfoProvider, EditDepthProvider } from '@payloadcms/ui'
+
+export const DocumentView: React.FC = ({
+ doc,
+ docPermissions,
+ docPreferences,
+ isLocked,
+ ...
+}) => {
+ return (
+
+
+
+
+ )
+}
+```
+
+**Key takeaway:** The data fetching and framework navigation live in the adapter's thin entry point (~20 lines). The actual UI rendering (~200+ lines) lives in packages/ui and is shared across all frameworks. Each adapter duplicates only the entry point glue, not the rendering logic.
+
+---
+
+## AdminAdapter Interface
+
+```typescript
+// packages/payload/src/admin/adapter/types.ts
+
+type AdminAdapterResult = {
+ init: (args: { payload: Payload }) => T
+ name: string
+}
+
+interface BaseAdminAdapter {
+ name: string
+ payload: Payload
+
+ // Server-side request handling
+ initReq: (args: {
+ config: SanitizedConfig
+ importMap: ImportMap
+ }) => Promise
+
+ // Cookie management
+ getCookie: (name: string) => string | undefined
+ setCookie: (name: string, value: string, options?: CookieOptions) => void
+ deleteCookie: (name: string) => void
+
+ // Server function dispatcher
+ handleServerFunctions: ServerFunctionHandler
+
+ // Server-side navigation (RSC)
+ notFound: () => never
+ redirect: (url: string) => never
+
+ // Client-side router provider
+ RouterProvider: React.ComponentType<{ children: React.ReactNode }>
+
+ // Route handler factories for REST + GraphQL
+ createRouteHandlers: (config: SanitizedConfig) => RouteHandlers
+
+ // Optional
+ generateMetadata?: (args: {
+ config: SanitizedConfig
+ }) => Promise>
+ destroy?: () => void | Promise
+}
+```
+
+**Config usage:**
+
+```typescript
+export default buildConfig({
+ db: postgresAdapter({ ... }),
+ admin: {
+ adapter: nextAdapter({ ... }), // optional, auto-detected
+ },
+})
+```
+
+### Auto-Detection (Backwards Compatibility)
+
+1. If `admin.adapter` is explicitly set, use it
+2. If not, check if `@payloadcms/next` is installed — auto-import and use as default
+3. If neither, throw clear error
+
+---
+
+## RouterProvider Abstraction
+
+```typescript
+// packages/ui/src/providers/Router/types.ts
+
+type RouterContextType = {
+ Link: React.ComponentType
+ useParams: () => Record
+ usePathname: () => string
+ useRouter: () => RouterInstance
+ useSearchParams: () => URLSearchParams
+}
+
+type RouterInstance = {
+ back: () => void
+ forward: () => void
+ prefetch: (url: string) => void
+ push: (url: string) => void
+ refresh: () => void
+ replace: (url: string) => void
+}
+```
+
+Replaces `next/navigation` / `next/link` imports in:
+
+- ~38 files in `packages/ui/src/` (elements, providers, views, forms, graphics, utilities)
+- ~7 client components in `packages/next/src/` (moved to packages/ui)
+
+---
+
+## Core Package Decoupling
+
+| Import | File | Resolution |
+| ------------------------ | ------------------------------------------------------ | ------------------------------------------------------------ |
+| `@next/env` | `packages/payload/src/bin/loadEnv.ts` | Replace with `dotenv` + `dotenv-expand` |
+| `Metadata` from `'next'` | `packages/payload/src/config/types.ts` | Define `PayloadMetadata` type (subset Payload actually uses) |
+| `ReadonlyRequestCookies` | `packages/payload/src/utilities/getRequestLanguage.ts` | Remove from union — callers pass `Map` |
+
+---
+
+## Data Flows
+
+**Admin page request:**
+
+```
+Request → Framework route handler
+ → adapter.initReq() (framework-specific: reads headers/cookies)
+ → PayloadRequest
+ → View (packages/ui) renders with data as props
+ → Components use RouterProvider for navigation
+ → Views use adapter.notFound() / adapter.redirect() for server navigation
+```
+
+**Server function call (e.g., form state):**
+
+```
+UI calls useServerFunctions().getFormState()
+ → ServerFunctionsProvider → adapter.handleServerFunctions()
+ → adapter.initReq()
+ → Dispatches to handler (buildFormStateHandler, etc.)
+ → Returns result
+```
+
+---
+
+## TanStack Start Adapter Shape
+
+```typescript
+export function tanstackStartAdapter(args?: TanStackStartArgs): AdminAdapterResult {
+ return {
+ name: 'tanstack-start',
+ init: ({ payload }) => createAdminAdapter({
+ name: 'tanstack-start',
+ payload,
+ initReq: // vinxi getWebRequest / getEvent
+ getCookie: // vinxi/http getCookie
+ setCookie: // vinxi/http setCookie
+ deleteCookie: // vinxi/http deleteCookie
+ handleServerFunctions: // TanStack createServerFn() wrapper
+ notFound: // throw notFound() from @tanstack/react-router
+ redirect: // throw redirect({ to: url }) from @tanstack/react-router
+ RouterProvider: // @tanstack/react-router hooks + Link
+ createRouteHandlers: // Vinxi API routes
+ })
+ }
+}
+```
+
+Build plugin as separate export:
+
+```typescript
+// user's app.config.ts
+import { withPayload } from '@payloadcms/tanstack-start/vite'
+export default withPayload(defineConfig({ ... }))
+```
+
+---
+
+## Implementation Phases
+
+### Phase 1: Define AdminAdapter Interface in Core
+
+#### Task 1: Create AdminAdapter types
+
+**Files:**
+
+- Create: `packages/payload/src/admin/adapter/types.ts`
+
+Write the `BaseAdminAdapter`, `AdminAdapterResult`, `CookieOptions`, `RouteHandler`, `RouteHandlers` types as shown in the interface section above. Include `notFound` and `redirect` in the interface.
+
+**Commit:** `feat: define AdminAdapter types`
+
+#### Task 2: Create createAdminAdapter helper
+
+**Files:**
+
+- Create: `packages/payload/src/admin/adapter/createAdminAdapter.ts`
+- Create: `packages/payload/src/admin/adapter/index.ts` (barrel export)
+- Modify: `packages/payload/src/index.ts` (add public exports)
+
+Factory helper modeled after `packages/payload/src/database/createDatabaseAdapter.ts`.
+
+**Commit:** `feat: add createAdminAdapter helper and exports`
+
+#### Task 3: Add adapter field to Config types
+
+**Files:**
+
+- Modify: `packages/payload/src/config/types.ts`
+
+Add `adapter?: AdminAdapterResult` to the `admin` block (around line 820). Add the import for `AdminAdapterResult`.
+
+**Commit:** `feat: add admin.adapter field to Config type`
+
+#### Task 4: Wire adapter initialization into Payload lifecycle
+
+**Files:**
+
+- Modify: `packages/payload/src/index.ts` (around line 881)
+
+Add `adminAdapter?: BaseAdminAdapter` property to Payload class. After db init, add:
+
+```typescript
+if (this.config.admin?.adapter) {
+ this.adminAdapter = this.config.admin.adapter.init({ payload: this })
+ this.adminAdapter.payload = this
+}
+```
+
+**Commit:** `feat: wire AdminAdapter initialization into Payload lifecycle`
+
+---
+
+### Phase 2: RouterProvider Abstraction
+
+#### Task 5: Create RouterProvider context in packages/ui
+
+**Files:**
+
+- Create: `packages/ui/src/providers/Router/types.ts`
+- Create: `packages/ui/src/providers/Router/index.tsx`
+- Modify: `packages/ui/src/exports/client/index.ts` (add exports)
+
+Create `RouterContext`, `RouterProvider`, and hook wrappers (`useRouter`, `usePathname`, `useSearchParams`, `useParams`, `Link`) that read from context.
+
+**Commit:** `feat(ui): add RouterProvider context and navigation hooks`
+
+#### Task 6: Add RouterProvider to RootProvider tree
+
+**Files:**
+
+- Modify: `packages/ui/src/providers/Root/index.tsx`
+
+Add optional `router?: RouterContextType` prop. Wrap with `` when provided. Keep backwards compatibility when not provided.
+
+**Commit:** `feat(ui): integrate RouterProvider into RootProvider tree`
+
+#### Task 7: Refactor UI files to use RouterProvider hooks
+
+**Files:**
+
+- Modify: ~38 files in `packages/ui/src/`
+
+Mechanical refactoring — replace all `import { useRouter } from 'next/navigation.js'` with `import { useRouter } from '../../providers/Router/index.js'` (adjust paths). Do in batches of ~10, build between batches.
+
+Full file list in implementation plan appendix.
+
+**Commit:** `refactor(ui): replace next/navigation imports with RouterProvider hooks`
+
+---
+
+### Phase 3: Decouple Core Package
+
+#### Task 8: Replace @next/env with dotenv
+
+**Files:**
+
+- Modify: `packages/payload/src/bin/loadEnv.ts`
+- Modify: `packages/payload/package.json`
+
+Replace `@next/env` with `dotenv` + `dotenv-expand`. Support same .env file priority (.env, .env.local, .env.development, .env.production).
+
+**Commit:** `refactor: replace @next/env with dotenv in core package`
+
+#### Task 9: Replace Metadata type with PayloadMetadata
+
+**Files:**
+
+- Modify: `packages/payload/src/config/types.ts`
+
+Remove `import type { Metadata } from 'next'`. Define `PayloadMetadata` type covering the subset Payload uses (title, description, openGraph, icons, twitter). Change `MetaConfig` from `& DeepClone` to `& PayloadMetadata`.
+
+**Commit:** `refactor: replace Next.js Metadata type with PayloadMetadata`
+
+#### Task 10: Remove ReadonlyRequestCookies from core
+
+**Files:**
+
+- Modify: `packages/payload/src/utilities/getRequestLanguage.ts`
+- Modify: `packages/ui/src/utilities/getRequestLanguage.ts` (if same import exists)
+
+Remove `ReadonlyRequestCookies` type. Change parameter to `cookies: Map`. Adapters convert their cookie format to Map before calling core.
+
+**Commit:** `refactor: remove ReadonlyRequestCookies Next.js type from core`
+
+#### Task 11: Remove 'next' from packages/payload dependencies
+
+**Files:**
+
+- Modify: `packages/payload/package.json`
+
+Verify zero `next` / `@next/*` imports remain, then remove from deps/peerDeps.
+
+**Commit:** `chore: remove next dependency from core payload package`
+
+---
+
+### Phase 4: Move Views from packages/next to packages/ui
+
+**Complexity: High** — this is the hardest phase due to RSC framework differences.
+
+#### Task 12: Split views into server entry + render component
+
+Views that call `initReq`, `notFound()`, or `redirect()` must be split:
+
+**Server entry (stays in packages/next):** Calls `initReq()`, fetches data, handles `notFound()`/`redirect()` at the framework level, passes results as props.
+
+**Render component (moves to packages/ui):** Receives data as props, renders UI. Uses `adapter.notFound()` / `adapter.redirect()` from a `ServerNavigation` hook/context only when needed for post-init logic.
+
+**Views requiring split (~12):**
+
+| View | Uses `initReq` | Uses `notFound()` | Uses `redirect()` |
+| ----------------- | -------------- | ----------------- | ----------------- |
+| Root | Yes | Yes | Yes |
+| NotFound | Yes | - | - |
+| Document | - | Yes | Yes |
+| Account | - | Yes | - |
+| List | - | Yes | - |
+| Login | - | - | Yes |
+| BrowseByFolder | - | Yes | Yes |
+| CollectionFolders | - | Yes | Yes |
+| CollectionTrash | - | Yes | - |
+| Version | - | Yes | - |
+| Versions | - | Yes | - |
+
+**Views that can move cleanly:** Dashboard, Edit, CreateFirstUser, ForgotPassword, ResetPassword, Logout, Verify, Unauthorized, API.
+
+**Client components (~7) that need RouterProvider swap:** TabLink, Nav client, LogoutClient, ResetPasswordForm, Verify client, API client, CreatedAtCell — same mechanical refactoring as Task 7.
+
+**Step 1:** Create `ServerNavigation` context in packages/ui for `notFound()` / `redirect()`:
+
+```typescript
+// packages/ui/src/providers/ServerNavigation/index.tsx
+const ServerNavigationContext = createContext<{
+ notFound: () => never
+ redirect: (url: string) => never
+}>()
+```
+
+Populated by the adapter at the root layout level.
+
+**Step 2:** For each view requiring split, extract the render component to packages/ui. The server entry in packages/next becomes thin:
+
+```typescript
+// packages/next/src/views/Document/index.tsx (stays in adapter)
+import { DocumentView } from '@payloadcms/ui/views'
+import { initReq } from '../../utilities/initReq.js'
+import { notFound, redirect } from 'next/navigation.js'
+
+export async function DocumentEntry(props) {
+ const { req, permissions } = await initReq(...)
+ const doc = await payload.findByID(...)
+ if (!doc) notFound()
+ if (!permissions.canRead) redirect('/admin/unauthorized')
+ return
+}
+```
+
+**Step 3:** Update packages/next export files to re-export from packages/ui for backwards compat.
+
+**Step 4:** Move client components, apply RouterProvider hook swap.
+
+**Commit:** `refactor: split views into server entries (adapter) and render components (ui)`
+
+---
+
+### Phase 5: Refactor packages/next to Implement AdminAdapter
+
+#### Task 13: Create nextAdapter() factory function
+
+**Files:**
+
+- Create: `packages/next/src/adapter/index.ts`
+- Create: `packages/next/src/adapter/RouterProvider.tsx`
+- Modify: `packages/next/src/index.js`
+
+Implement `nextAdapter()` returning `AdminAdapterResult`. Wire existing `initReq`, `handleServerFunctions`, cookie helpers. Create `NextRouterProvider` wrapping `next/navigation` hooks. Implement `notFound` and `redirect` using `next/navigation`.
+
+**Commit:** `feat(next): create nextAdapter() implementing AdminAdapter interface`
+
+#### Task 14: Wire NextRouterProvider into Root Layout
+
+**Files:**
+
+- Modify: `packages/next/src/layouts/Root/index.tsx`
+
+Pass router context and `ServerNavigation` (notFound/redirect) to RootProvider.
+
+**Commit:** `feat(next): wire NextRouterProvider into admin root layout`
+
+---
+
+### Phase 6: CLI and Template Changes
+
+#### Task 15: Add --framework flag to create-payload-app
+
+**Files:**
+
+- Modify: `packages/create-payload-app/src/types.ts` (add `FrameworkType`, `--framework` arg)
+- Modify: `packages/create-payload-app/src/main.ts` (add flag + alias, call framework prompt)
+- Create: `packages/create-payload-app/src/lib/select-framework.ts` (prompt logic)
+
+Add `--framework next|tanstack-start` flag (alias `-f`). Add interactive prompt similar to database selection. Default: `next`.
+
+**Commit:** `feat(create-payload-app): add --framework flag and framework selection prompt`
+
+#### Task 16: Update templates for framework support
+
+**Files:**
+
+- Create: `templates/blank-tanstack-start/` (TanStack Start blank template)
+- Modify: `packages/create-payload-app/src/lib/templates.ts` (framework filtering)
+- Modify: `packages/create-payload-app/src/lib/ast/payload-config.ts` (adapter imports)
+- Modify: `packages/create-payload-app/src/lib/create-project.ts` (pass framework)
+
+Separate template directories per framework (different project structure). Update AST to insert correct adapter import and config.
+
+**Commit:** `feat(create-payload-app): add framework-specific templates and AST config`
+
+---
+
+### Phase 7: TanStack Start Proof-of-Concept
+
+#### Task 17: Scaffold packages/tanstack-start
+
+**Files:**
+
+- Create: `packages/tanstack-start/package.json`
+- Create: `packages/tanstack-start/src/index.ts`
+- Create: `packages/tanstack-start/src/adapter/index.ts`
+- Create: `packages/tanstack-start/src/adapter/RouterProvider.tsx`
+- Create: `packages/tanstack-start/src/adapter/initReq.ts`
+- Create: `packages/tanstack-start/src/vite/index.ts` (withPayload Vite plugin)
+
+Implement full `AdminAdapterResult` using:
+
+- `vinxi/http` for request context and cookies
+- `@tanstack/start` `createServerFn()` for server functions
+- `@tanstack/react-router` hooks for RouterProvider
+- `@tanstack/react-router` `notFound()` / `redirect()` for server navigation
+
+**Commit:** `feat: scaffold @payloadcms/tanstack-start adapter package`
+
+---
+
+### Phase 8: Testing
+
+#### Task 18: Verify packages/next works identically
+
+Run full test suite — all existing tests must pass unchanged:
+
+```bash
+pnpm run test:int
+pnpm run test:e2e
+pnpm run dev # manual smoke test
+```
+
+#### Task 19: Add adapter-specific integration tests
+
+**Files:**
+
+- Create: `test/admin-adapter/config.ts`
+- Create: `test/admin-adapter/int.spec.ts`
+
+Test: explicit adapter config works, auto-detection works, missing adapter errors clearly, `payload.adminAdapter` is set.
+
+**Commit:** `test: add AdminAdapter integration tests`
+
+#### Task 20: Refactor e2e test architecture for multi-framework support
+
+The current e2e test suite (Playwright) is tightly coupled to Next.js — the test harness spins up a Next.js dev server and tests assume Next.js routing behavior. This needs to become framework-agnostic.
+
+**Current architecture:**
+
+- Test harness starts a Next.js dev server per test suite
+- Playwright navigates `localhost:3000/admin`
+- Tests assume Next.js route transitions, loading states, error pages
+
+**Target architecture:**
+
+**Step 1: Abstract the dev server harness**
+
+Create a `FrameworkTestHarness` interface:
+
+```ts
+interface FrameworkTestHarness {
+ start(config: PayloadTestConfig): Promise<{ url: string }>
+ stop(): Promise
+}
+```
+
+Each adapter provides its own harness:
+
+- `@payloadcms/next` → starts Next.js dev server (existing behavior)
+- `@payloadcms/tanstack-start` → starts Vinxi dev server
+
+The harness is selected via `PAYLOAD_FRAMEWORK` env var (defaults to `next`).
+
+**Step 2: Make tests framework-agnostic**
+
+- Remove any assertions that depend on Next.js-specific behavior (route transition animations, specific error page markup, etc.)
+- Tests should assert on Payload admin UI behavior, not framework behavior
+- Use `data-testid` attributes and accessibility snapshots rather than framework-specific selectors
+
+**Step 3: Run e2e matrix in CI**
+
+Update `.github/workflows/main.yml` to run e2e tests against each framework:
+
+```yaml
+strategy:
+ matrix:
+ framework: [next, tanstack-start]
+env:
+ PAYLOAD_FRAMEWORK: ${{ matrix.framework }}
+```
+
+**Step 4: Shared vs adapter-specific e2e tests**
+
+- **Shared tests** (~90%): Admin panel CRUD, navigation, auth, forms — these should work identically on any framework
+- **Adapter-specific tests** (~10%): Framework-specific features like Next.js OG images, metadata, build config
+
+```
+test/
+├── e2e/
+│ ├── shared/ # framework-agnostic admin tests
+│ ├── next/ # Next.js-specific e2e tests
+│ └── tanstack-start/ # TanStack Start-specific e2e tests
+```
+
+**Commit:** `test: refactor e2e test architecture for multi-framework support`
+
+---
+
+## Work Items Summary
+
+| Work Item | Complexity | Phase |
+| --------------------------------------------------------------------- | ---------------- | ----- |
+| Define `AdminAdapter` interface (with `notFound`/`redirect`) | Medium | 1 |
+| Create `createAdminAdapter` helper + exports | Low | 1 |
+| Add `admin.adapter` to Config types | Low | 1 |
+| Wire adapter into Payload init lifecycle | Low | 1 |
+| Create `RouterProvider` context + hooks in `packages/ui` | Low | 2 |
+| Add `RouterProvider` to `RootProvider` tree | Low | 2 |
+| Refactor ~45 UI files to use `RouterProvider` (38 existing + 7 moved) | Low (mechanical) | 2 |
+| Replace `@next/env` with `dotenv` | Low | 3 |
+| Replace `Metadata` type with `PayloadMetadata` | Low | 3 |
+| Remove `ReadonlyRequestCookies` from core | Trivial | 3 |
+| Remove `next` from core dependencies | Trivial | 3 |
+| Split ~12 views into server entry + render component | **High** | 4 |
+| Move clean views + client components to packages/ui | Medium | 4 |
+| Create `ServerNavigation` context for `notFound`/`redirect` | Medium | 4 |
+| Refactor `packages/next` to implement `AdminAdapter` | **High** | 5 |
+| Wire `NextRouterProvider` into root layout | Low | 5 |
+| CLI `--framework` flag + framework selection prompt | Medium | 6 |
+| Framework-specific templates | Medium-High | 6 |
+| Scaffold `@payloadcms/tanstack-start` | **High** | 7 |
+| Testing — integration + adapter-specific | Medium | 8 |
+| Testing — refactor e2e architecture for multi-framework | **High** | 8 |
+| Testing — CI matrix for framework e2e | Medium | 8 |
+
+## Dependency Graph
+
+```
+Phase 1 (Tasks 1-4): Foundation
+ ↓
+Phase 2 (Tasks 5-7) ──parallel── Phase 3 (Tasks 8-11)
+ ↓ ↓
+Phase 4 (Task 12): Split & move views ← depends on both
+ ↓
+Phase 5 (Tasks 13-14): Refactor packages/next
+ ↓
+Phase 6 (Tasks 15-16): CLI + Templates ← can start after Phase 1
+ ↓
+Phase 7 (Task 17): TanStack Start PoC ← depends on Phase 5
+ ↓
+Phase 8 (Tasks 18-19): Testing ← depends on all
+```
+
+**Parallelizable:** Phase 2 + Phase 3. Phase 6 can start early (after Phase 1).
diff --git a/docs/plans/2026-04-01-tanstack-start-adapter.md b/docs/plans/2026-04-01-tanstack-start-adapter.md
new file mode 100644
index 00000000000..434bb1a86c5
--- /dev/null
+++ b/docs/plans/2026-04-01-tanstack-start-adapter.md
@@ -0,0 +1,1516 @@
+# TanStack Start Adapter — Full Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Complete the TanStack Start admin adapter with a proper implementation by moving all framework-agnostic code from `packages/next` to `packages/ui`, then building a real adapter using `vinxi/http` and `@tanstack/react-router`.
+
+**Architecture:** Three-stage process: (1) extract all framework-agnostic utilities and components from `packages/next` to `packages/ui`, (2) refactor `renderDocument`/`renderListView`/etc. to accept `notFound`/`redirect` as callbacks making them moveable, (3) implement `packages/tanstack-start` using `vinxi/http` cookies, `getWebRequest()` for initReq, and real `@tanstack/react-router` hooks.
+
+**Tech Stack:** TypeScript, React 19 RSC, Payload CMS monorepo (pnpm + Turbo), vinxi/http, @tanstack/react-router, @tanstack/start
+
+---
+
+## Current State
+
+The original plan (`2026-03-31-admin-adapter-combined.md`) is fully implemented. `packages/tanstack-start` exists with stub methods that throw errors. This plan completes it.
+
+**What `packages/next` still exclusively owns (needs to move):**
+
+- `elements/Nav/` — full Nav server+client component
+- `elements/DocumentHeader/` — DocumentHeader with Tabs
+- `templates/Default/` and `templates/Minimal/`
+- `utilities/selectiveCache.ts`, `getRequestLocale.ts`, `getPreferences.ts`, `handleAuthRedirect.ts`, `slugify.ts`, `isCustomAdminView.ts`, `isPublicAdminRoute.ts`, `getRouteWithoutAdmin.ts`, `timestamp.ts`
+- `views/Document/` utilities: `getDocumentData`, `getDocumentPermissions`, `getIsLocked`, `getVersions`, `getDocumentView`, `getCustomDocumentViewByKey`, `getCustomViewByRoute`, `renderDocumentSlots`, `handleServerFunction`
+- `views/List/` utilities: `enrichDocsWithVersionStatus`, `handleGroupBy`, `renderListViewSlots`, `resolveAllFilterOptions`, `transformColumnsToSelect`, `createSerializableValue`, `extractRelationshipDisplayValue`, `extractValueOrRelationshipID`, `handleServerFunction`
+- `views/Document/index.tsx` and `views/List/index.tsx` — main render functions using `notFound`/`redirect`
+- `views/Account/` client files, `views/Version/`, `views/Versions/` utilities
+
+**Blocking the TanStack adapter:**
+
+- `renderDocument` and `renderListView` call `notFound()`/`redirect()` from `next/navigation` — to move them to packages/ui, we must pass these as callbacks
+- `handleServerFunctions` registry lives in packages/next and references view handlers that haven't moved yet
+
+---
+
+## Phase 1: Move Framework-Agnostic Utilities
+
+### Task 1: Move packages/next utilities to packages/ui
+
+**Files:**
+
+- Copy: `packages/next/src/utilities/selectiveCache.ts` → `packages/ui/src/utilities/selectiveCache.ts`
+- Copy: `packages/next/src/utilities/getRequestLocale.ts` → `packages/ui/src/utilities/getRequestLocale.ts`
+- Copy: `packages/next/src/utilities/handleAuthRedirect.ts` → `packages/ui/src/utilities/handleAuthRedirect.ts`
+- Copy: `packages/next/src/utilities/isCustomAdminView.ts` → `packages/ui/src/utilities/isCustomAdminView.ts`
+- Copy: `packages/next/src/utilities/isPublicAdminRoute.ts` → `packages/ui/src/utilities/isPublicAdminRoute.ts`
+- Copy: `packages/next/src/utilities/getRouteWithoutAdmin.ts` → `packages/ui/src/utilities/getRouteWithoutAdmin.ts`
+- Copy: `packages/next/src/utilities/timestamp.ts` → `packages/ui/src/utilities/timestamp.ts`
+- Copy: `packages/next/src/utilities/slugify.ts` → `packages/ui/src/utilities/slugify.ts` (the server function handler)
+
+**Steps:**
+
+1. Copy each file to packages/ui/src/utilities/
+2. Fix any `@payloadcms/ui` self-imports → relative paths
+3. Update packages/next to re-import from packages/ui where needed
+4. Run: `pnpm run build:ui && pnpm run build:next` — expect no TypeScript errors
+5. Commit: `refactor(ui): move shared utilities from packages/next to packages/ui`
+
+**Note:** `getPreferences.ts` is already in `packages/ui/src/utilities/getPreferences.ts` (moved in previous work). `getRequestLocale.ts` imports from `@payloadcms/ui/rsc` and `@payloadcms/ui/shared` — replace those with relative imports.
+
+---
+
+### Task 2: Move Nav and DocumentHeader elements
+
+**Files:**
+
+- Copy: `packages/next/src/elements/Nav/index.tsx` → `packages/ui/src/elements/Nav/index.tsx`
+- Copy: `packages/next/src/elements/Nav/index.client.tsx` → `packages/ui/src/elements/Nav/index.client.tsx`
+- Copy: `packages/next/src/elements/Nav/NavHamburger/index.tsx` → `packages/ui/src/elements/Nav/NavHamburger/index.tsx`
+- Copy: `packages/next/src/elements/Nav/NavWrapper/index.tsx` → `packages/ui/src/elements/Nav/NavWrapper/index.tsx`
+- Copy: `packages/next/src/elements/Nav/SettingsMenuButton/index.tsx` → `packages/ui/src/elements/Nav/SettingsMenuButton/index.tsx`
+- Copy: `packages/next/src/elements/Nav/getNavPrefs.ts` → `packages/ui/src/elements/Nav/getNavPrefs.ts`
+- Copy: `packages/next/src/elements/DocumentHeader/` (entire dir) → `packages/ui/src/elements/DocumentHeader/`
+
+**Steps:**
+
+1. Check that `packages/ui/src/elements/Nav/context.tsx` already exists (the NavProvider/useNav hook). The Nav server component from packages/next is different — it renders the full nav sidebar.
+2. Copy files, fix `@payloadcms/ui` self-imports → relative paths
+3. Update packages/next Nav exports to re-export from packages/ui
+4. Run: `pnpm run build:ui && pnpm run build:next`
+5. Commit: `refactor(ui): move Nav and DocumentHeader elements from packages/next`
+
+---
+
+### Task 3: Move templates to packages/ui
+
+**Files:**
+
+- Create: `packages/ui/src/templates/Default/index.tsx` (from `packages/next/src/templates/Default/index.tsx`)
+- Create: `packages/ui/src/templates/Default/NavHamburger/index.tsx`
+- Create: `packages/ui/src/templates/Default/Wrapper/index.tsx`
+- Create: `packages/ui/src/templates/Minimal/index.tsx`
+
+**Steps:**
+
+1. Copy each file, convert `@payloadcms/ui` imports to relative paths
+2. Add `"./templates/*"` export pattern to `packages/ui/package.json` exports
+3. Update `packages/next/src/templates/` to re-export from `@payloadcms/ui/templates/*`
+4. Run: `pnpm run build:ui && pnpm run build:next`
+5. Commit: `refactor(ui): move Default and Minimal templates from packages/next`
+
+---
+
+## Phase 2: Move View Utilities
+
+### Task 4: Move Document view utilities
+
+**Files to move (all have zero next/\* imports):**
+
+- `packages/next/src/views/Document/getDocumentData.ts` → `packages/ui/src/views/Document/getDocumentData.ts` (already exists — verify)
+- `packages/next/src/views/Document/getDocumentPermissions.tsx` → `packages/ui/src/views/Document/getDocumentPermissions.tsx`
+- `packages/next/src/views/Document/getIsLocked.ts` → `packages/ui/src/views/Document/getIsLocked.ts`
+- `packages/next/src/views/Document/getVersions.ts` → `packages/ui/src/views/Document/getVersions.ts`
+- `packages/next/src/views/Document/getDocumentView.tsx` → `packages/ui/src/views/Document/getDocumentView.tsx`
+- `packages/next/src/views/Document/getCustomDocumentViewByKey.tsx` → `packages/ui/src/views/Document/getCustomDocumentViewByKey.tsx`
+- `packages/next/src/views/Document/getCustomViewByRoute.tsx` → `packages/ui/src/views/Document/getCustomViewByRoute.tsx`
+- `packages/next/src/views/Document/renderDocumentSlots.tsx` → `packages/ui/src/views/Document/renderDocumentSlots.tsx`
+
+**Steps:**
+
+1. `getDocumentData.ts` and `getDocPreferences.ts` already exist in packages/ui — verify they're correct copies
+2. Copy remaining files, fix self-imports
+3. Update packages/next to import these from `@payloadcms/ui/views/Document/*` (add export paths if needed) or relative package imports
+4. Run: `pnpm run build:ui && pnpm run build:next`
+5. Commit: `refactor(ui): move Document view utilities from packages/next`
+
+---
+
+### Task 5: Move List view utilities
+
+**Files to move (all have zero next/\* imports):**
+
+- `packages/next/src/views/List/createSerializableValue.ts` → `packages/ui/src/views/List/createSerializableValue.ts`
+- `packages/next/src/views/List/enrichDocsWithVersionStatus.ts` → `packages/ui/src/views/List/enrichDocsWithVersionStatus.ts`
+- `packages/next/src/views/List/extractRelationshipDisplayValue.ts` → `packages/ui/src/views/List/extractRelationshipDisplayValue.ts`
+- `packages/next/src/views/List/extractValueOrRelationshipID.ts` → `packages/ui/src/views/List/extractValueOrRelationshipID.ts`
+- `packages/next/src/views/List/handleGroupBy.ts` → `packages/ui/src/views/List/handleGroupBy.ts`
+- `packages/next/src/views/List/renderListViewSlots.tsx` → `packages/ui/src/views/List/renderListViewSlots.tsx`
+- `packages/next/src/views/List/resolveAllFilterOptions.ts` → `packages/ui/src/views/List/resolveAllFilterOptions.ts`
+- `packages/next/src/views/List/transformColumnsToSelect.ts` → `packages/ui/src/views/List/transformColumnsToSelect.ts`
+
+**Steps:**
+
+1. Copy files, fix self-imports
+2. Run: `pnpm run build:ui && pnpm run build:next`
+3. Commit: `refactor(ui): move List view utilities from packages/next`
+
+---
+
+### Task 6: Move Version/Versions and Account utilities
+
+**Files:**
+
+- `packages/next/src/views/Version/fetchVersions.ts` → `packages/ui/src/views/Version/fetchVersions.ts`
+- `packages/next/src/views/Version/RenderFieldsToDiff/` (entire dir) → `packages/ui/src/views/Version/RenderFieldsToDiff/`
+- `packages/next/src/views/Version/SelectComparison/` → `packages/ui/src/views/Version/SelectComparison/`
+- `packages/next/src/views/Version/VersionPillLabel/` → `packages/ui/src/views/Version/VersionPillLabel/`
+- `packages/next/src/views/Version/Default/` (minus index.tsx) → `packages/ui/src/views/Version/Default/`
+- `packages/next/src/views/Versions/buildColumns.tsx` → `packages/ui/src/views/Versions/buildColumns.tsx`
+- `packages/next/src/views/Versions/cells/` → `packages/ui/src/views/Versions/cells/`
+- `packages/next/src/views/Account/index.client.tsx` → `packages/ui/src/views/Account/index.client.tsx`
+- `packages/next/src/views/Account/ResetPreferences/` → `packages/ui/src/views/Account/ResetPreferences/`
+- `packages/next/src/views/Account/Settings/` → `packages/ui/src/views/Account/Settings/`
+- `packages/next/src/views/Account/ToggleTheme/` → `packages/ui/src/views/Account/ToggleTheme/`
+
+**Steps:**
+
+1. Copy files, fix self-imports
+2. Run: `pnpm run build:ui && pnpm run build:next`
+3. Commit: `refactor(ui): move Version/Versions/Account utilities from packages/next`
+
+---
+
+## Phase 3: Refactor Main View Functions to Accept Navigation Callbacks
+
+The `renderDocument`, `renderListView`, `renderVersion`, `renderVersions`, and `renderAccount` functions in `packages/next` call `notFound()`/`redirect()` from `next/navigation`. To move them to `packages/ui`, we add these as parameters.
+
+### Task 7: Add ServerNavigation params to renderDocument and move to packages/ui
+
+**Files:**
+
+- Modify: `packages/next/src/views/Document/index.tsx`
+- Create: `packages/ui/src/views/Document/RenderDocument.tsx` (the moved render function)
+- Modify: `packages/next/src/views/Document/handleServerFunction.tsx`
+
+**The pattern:** Extract `renderDocument` from `packages/next` to `packages/ui` with two new parameters:
+
+```typescript
+// packages/ui/src/views/Document/RenderDocument.tsx
+export const renderDocument = async ({
+ // ...existing params...
+ notFound, // ADD: () => never
+ redirect, // ADD: (url: string) => never
+}: {
+ // ... existing type ...
+ notFound: () => never
+ redirect: (url: string) => never
+}): Promise<{ data: Data; Document: React.ReactNode }> => {
+ // same implementation, but calls params.notFound() and params.redirect()
+ // instead of importing from next/navigation
+}
+```
+
+**Steps:**
+
+1. Create `packages/ui/src/views/Document/RenderDocument.tsx`:
+
+ - Copy the entire `renderDocument` function from `packages/next/src/views/Document/index.tsx`
+ - Add `notFound: () => never` and `redirect: (url: string) => never` to the function params type
+ - Replace the `import { notFound, redirect } from 'next/navigation.js'` with no import
+ - Replace all `notFound()` calls with `notFound()` (params) and `redirect(url)` with `redirect(url)` (params)
+ - Fix any `@payloadcms/ui` self-imports → relative paths
+ - Fix imports of `DocumentHeader` → from `../../elements/DocumentHeader/index.js`
+ - Fix imports of Document utilities → relative `./getDocumentData.js` etc.
+
+2. Update `packages/next/src/views/Document/index.tsx` to:
+
+ ```typescript
+ import { notFound, redirect } from 'next/navigation.js'
+ import { renderDocument as renderDocumentFromUI } from '@payloadcms/ui/views/Document/RenderDocument'
+
+ // Re-export renderDocument passing in Next.js's notFound/redirect
+ export const renderDocument = (args) =>
+ renderDocumentFromUI({ ...args, notFound, redirect })
+ ```
+
+3. Update `packages/next/src/views/Document/handleServerFunction.tsx` to import from the updated location
+
+4. Run: `pnpm run build:ui && pnpm run build:next`
+5. Commit: `refactor(ui): move renderDocument to packages/ui with navigation callbacks`
+
+---
+
+### Task 8: Move renderListView to packages/ui with navigation callbacks
+
+**Files:**
+
+- Create: `packages/ui/src/views/List/RenderListView.tsx` (the moved render function)
+- Modify: `packages/next/src/views/List/index.tsx`
+- Modify: `packages/next/src/views/List/handleServerFunction.tsx`
+
+**Steps:**
+
+1. Create `packages/ui/src/views/List/RenderListView.tsx`:
+
+ - Copy `renderListView` function from `packages/next/src/views/List/index.tsx`
+ - Add `notFound: () => never` to params
+ - Replace `notFound()` call with the param
+ - Fix imports → relative paths within packages/ui
+
+2. Update `packages/next/src/views/List/index.tsx`:
+
+ ```typescript
+ import { notFound } from 'next/navigation.js'
+ import { renderListView as renderListViewFromUI } from '@payloadcms/ui/views/List/RenderListView'
+
+ export const renderListView = (args) =>
+ renderListViewFromUI({ ...args, notFound })
+ ```
+
+3. Run: `pnpm run build:ui && pnpm run build:next`
+4. Commit: `refactor(ui): move renderListView to packages/ui with notFound callback`
+
+---
+
+### Task 9: Move renderVersion, renderVersions, renderAccount to packages/ui
+
+**Same pattern as Tasks 7-8, applied to:**
+
+- `packages/next/src/views/Version/index.tsx` → `packages/ui/src/views/Version/RenderVersion.tsx`
+- `packages/next/src/views/Versions/index.tsx` → `packages/ui/src/views/Versions/RenderVersions.tsx`
+- `packages/next/src/views/Account/index.tsx` → `packages/ui/src/views/Account/RenderAccount.tsx`
+
+For Version and Versions: add `notFound: () => never` to params.
+For Account: add `notFound: () => never` to params.
+
+**Steps:**
+
+1. For each view:
+ - Create `RenderX.tsx` in packages/ui with navigation callback params
+ - Update packages/next entry to import from packages/ui and pass Next.js callbacks
+2. Run: `pnpm run build:ui && pnpm run build:next`
+3. Commit: `refactor(ui): move Version/Versions/Account render functions to packages/ui`
+
+---
+
+## Phase 4: Move handleServerFunction Handlers to packages/ui
+
+### Task 10: Move renderDocumentHandler and renderListHandler
+
+These server function handlers (`handleServerFunction.tsx` in each view) call `renderDocument`/`renderListView`. Now that those functions are in packages/ui and accept navigation callbacks, the handlers can also move.
+
+**Files:**
+
+- Move: `packages/next/src/views/Document/handleServerFunction.tsx` → `packages/ui/src/views/Document/handleServerFunction.tsx`
+- Move: `packages/next/src/views/List/handleServerFunction.tsx` → `packages/ui/src/views/List/handleServerFunction.tsx`
+
+**Key:** These handlers don't call `notFound`/`redirect` themselves — they call `renderDocument`/`renderListView` which accept callbacks. But the callbacks need to come from somewhere when invoked in a server function context.
+
+**Solution:** Server function handlers accept `notFound` and `redirect` from the `ServerFunctionArgs` context (which the adapter injects via `initReq`). Add these to `DefaultServerFunctionArgs`:
+
+```typescript
+// packages/ui/src/views/Document/handleServerFunction.tsx
+export const renderDocumentHandler: RenderDocumentServerFunction = async (args) => {
+ const { notFound, redirect } = args // injected by adapter's handleServerFunctions
+ const { data, Document } = await renderDocument({
+ ...renderArgs,
+ notFound: notFound ?? (() => { throw new Error('notFound not provided') }),
+ redirect: redirect ?? (() => { throw new Error('redirect not provided') }),
+ })
+ ...
+}
+```
+
+**Steps:**
+
+1. Check the `RenderDocumentServerFunction` type in `packages/ui` — does it include `notFound`/`redirect`? If not, extend it in `packages/payload/src/admin/types.ts`
+2. Update `DefaultServerFunctionArgs` in packages/payload to include optional `notFound`/`redirect`
+3. Create `packages/ui/src/views/Document/handleServerFunction.tsx` with updated handler
+4. Create `packages/ui/src/views/List/handleServerFunction.tsx` with updated handler
+5. Update packages/next handlers to be thin re-exports
+6. Run: `pnpm run build:ui && pnpm run build:next`
+7. Commit: `refactor(ui): move renderDocumentHandler and renderListHandler to packages/ui`
+
+---
+
+### Task 11: Create shared handleServerFunctions base in packages/ui
+
+**File:** Create `packages/ui/src/utilities/handleServerFunctions.ts`
+
+```typescript
+// packages/ui/src/utilities/handleServerFunctions.ts
+import type {
+ DefaultServerFunctionArgs,
+ ServerFunction,
+ ServerFunctionHandler,
+} from 'payload'
+
+import {
+ _internal_renderFieldHandler,
+ copyDataFromLocaleHandler,
+} from '../exports/rsc/index.js'
+import { buildFormStateHandler } from './buildFormState.js'
+import { buildTableStateHandler } from './buildTableState.js'
+import { getFolderResultsComponentAndDataHandler } from './getFolderResultsComponentAndData.js'
+import { schedulePublishHandler } from './schedulePublishHandler.js'
+import { getDefaultLayoutHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/getDefaultLayoutServerFn.js'
+import { renderWidgetHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.js'
+import { renderDocumentHandler } from '../views/Document/handleServerFunction.js'
+import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlots.js'
+import { renderListHandler } from '../views/List/handleServerFunction.js'
+import { slugifyHandler } from './slugify.js'
+
+export const baseServerFunctions: Record> = {
+ 'copy-data-from-locale': copyDataFromLocaleHandler,
+ 'form-state': buildFormStateHandler,
+ 'get-default-layout': getDefaultLayoutHandler,
+ 'get-folder-results-component-and-data':
+ getFolderResultsComponentAndDataHandler,
+ 'render-document': renderDocumentHandler,
+ 'render-document-slots': renderDocumentSlotsHandler,
+ 'render-field': _internal_renderFieldHandler,
+ 'render-list': renderListHandler,
+ 'render-widget': renderWidgetHandler,
+ 'schedule-publish': schedulePublishHandler,
+ slugify: slugifyHandler,
+ 'table-state': buildTableStateHandler,
+}
+
+/**
+ * Framework-agnostic server function dispatcher.
+ * Adapters call this after running initReq to get the request context.
+ */
+export async function dispatchServerFunction(args: {
+ augmentedArgs: DefaultServerFunctionArgs
+ extraServerFunctions?: Record
+ name: string
+}): Promise {
+ const { augmentedArgs, extraServerFunctions, name } = args
+ const fn = extraServerFunctions?.[name] || baseServerFunctions[name]
+ if (!fn) {
+ throw new Error(`Unknown Server Function: ${name}`)
+ }
+ return fn(augmentedArgs)
+}
+```
+
+**Steps:**
+
+1. Create the file above
+2. Update `packages/next/src/utilities/handleServerFunctions.ts` to:
+
+ ```typescript
+ import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions'
+
+ export const handleServerFunctions: ServerFunctionHandler = async (args) => {
+ const {
+ name: fnKey,
+ args: fnArgs,
+ config,
+ importMap,
+ serverFunctions,
+ } = args
+ const { cookies, locale, permissions, req } = await initReq({
+ configPromise: config,
+ importMap,
+ key: 'RootLayout',
+ })
+ const augmentedArgs = {
+ ...fnArgs,
+ cookies,
+ importMap,
+ locale,
+ permissions,
+ req,
+ notFound: () => notFound(), // inject Next.js navigation
+ redirect: (url) => redirect(url),
+ }
+ return dispatchServerFunction({
+ augmentedArgs,
+ extraServerFunctions: serverFunctions,
+ name: fnKey,
+ })
+ }
+ ```
+
+3. Run: `pnpm run build:ui && pnpm run build:next`
+4. Commit: `refactor(ui): create shared server function dispatcher`
+
+---
+
+## Phase 5: Implement TanStack Start Adapter
+
+### Task 12: Implement initReq for TanStack Start
+
+**File:** Create `packages/tanstack-start/src/utilities/initReq.ts`
+
+```typescript
+import type { I18n, I18nClient } from '@payloadcms/translations'
+import type { ImportMap, InitReqResult, SanitizedConfig } from 'payload'
+
+import { initI18n } from '@payloadcms/translations'
+// vinxi/http provides server-side request context in TanStack Start
+import { getCookie as vinxiGetCookie, getWebRequest } from 'vinxi/http'
+import {
+ createLocalReq,
+ executeAuthStrategies,
+ getAccessResults,
+ getPayload,
+ getRequestLanguage,
+ parseCookies,
+} from 'payload'
+
+import { getRequestLocale } from '@payloadcms/ui/utilities/getRequestLocale'
+import { selectiveCache } from '@payloadcms/ui/utilities/selectiveCache'
+
+const partialReqCache = selectiveCache('partialReq')
+const reqCache = selectiveCache('req')
+
+export const initReq = async function ({
+ config: configArg,
+ importMap,
+ key = 'adapter',
+}: {
+ config: Promise | SanitizedConfig
+ importMap: ImportMap
+ key?: string
+}): Promise {
+ // getWebRequest() returns the current server request from Vinxi's context store
+ const request = getWebRequest()
+ const headers = new Headers(request.headers)
+ const cookies = parseCookies(headers)
+
+ const partialResult = await partialReqCache.get(async () => {
+ const config = await configArg
+ const payload = await getPayload({ config, cron: true, importMap })
+ const languageCode = getRequestLanguage({ config, cookies, headers })
+
+ const i18n: I18nClient = await initI18n({
+ config: config.i18n,
+ context: 'client',
+ language: languageCode,
+ })
+
+ const { responseHeaders, user } = await executeAuthStrategies({
+ headers,
+ payload,
+ })
+
+ return { i18n, languageCode, payload, responseHeaders, user }
+ }, 'global')
+
+ return reqCache
+ .get(async () => {
+ const { i18n, languageCode, payload, responseHeaders, user } =
+ partialResult
+
+ const req = await createLocalReq(
+ {
+ req: {
+ headers,
+ host: headers.get('host'),
+ i18n: i18n as I18n,
+ responseHeaders,
+ url: request.url,
+ user,
+ },
+ },
+ payload,
+ )
+
+ const locale = await getRequestLocale({ req })
+ req.locale = locale?.code
+
+ const permissions = await getAccessResults({ req })
+
+ return { cookies, headers, languageCode, locale, permissions, req }
+ }, key)
+ .then((result) => ({
+ ...result,
+ req: {
+ ...result.req,
+ ...(result.req?.context ? { context: { ...result.req.context } } : {}),
+ },
+ }))
+}
+```
+
+**Steps:**
+
+1. Create the file
+2. Add `vinxi` to `packages/tanstack-start/package.json` peerDependencies (already there)
+3. Verify `getWebRequest` import path — check vinxi docs or `node_modules/vinxi/dist` for correct import
+4. Run: `cd packages/tanstack-start && pnpm tsc --noEmit` — expect no errors on the new file (warnings about peer deps OK)
+5. Commit: `feat(tanstack-start): implement initReq using vinxi/http getWebRequest`
+
+---
+
+### Task 13: Implement handleServerFunctions for TanStack Start
+
+**File:** Create `packages/tanstack-start/src/utilities/handleServerFunctions.ts`
+
+```typescript
+import type { ServerFunctionHandler } from 'payload'
+
+import { notFound, redirect } from '@tanstack/react-router'
+import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions'
+
+import { initReq } from './initReq.js'
+
+export const handleServerFunctions: ServerFunctionHandler = async (args) => {
+ const { name: fnKey, args: fnArgs, config, importMap, serverFunctions } = args
+
+ const { cookies, locale, permissions, req } = await initReq({
+ config,
+ importMap,
+ key: 'RootLayout',
+ })
+
+ const augmentedArgs = {
+ ...fnArgs,
+ cookies,
+ importMap,
+ locale,
+ notFound: () => {
+ throw notFound()
+ },
+ permissions,
+ redirect: (url: string) => {
+ throw redirect({ to: url })
+ },
+ req,
+ }
+
+ return dispatchServerFunction({
+ augmentedArgs,
+ extraServerFunctions: serverFunctions,
+ name: fnKey,
+ })
+}
+```
+
+**Steps:**
+
+1. Create the file
+2. Verify `@tanstack/react-router` exports `notFound` and `redirect` — check their API: `notFound()` returns a special error object to throw, `redirect({ to })` does the same
+3. Commit: `feat(tanstack-start): implement handleServerFunctions using shared dispatcher`
+
+---
+
+### Task 14: Implement real RouterProvider with @tanstack/react-router hooks
+
+**File:** Replace `packages/tanstack-start/src/adapter/RouterProvider.tsx`
+
+```typescript
+'use client'
+import type { LinkProps as RouterLinkProps, RouterContextType } from '@payloadcms/ui'
+
+import { RouterProvider as BaseRouterProvider } from '@payloadcms/ui'
+import { Link as TanStackLink, useLocation, useParams, useRouter } from '@tanstack/react-router'
+import React from 'react'
+
+const AdapterLink: React.FC = ({ href, children, ...rest }) => (
+
+ {children}
+
+)
+
+export function TanStackRouterProvider({ children }: { children: React.ReactNode }) {
+ const router = useRouter()
+ const location = useLocation()
+ const params = useParams({ strict: false })
+
+ const searchParams = React.useMemo(
+ () => new URLSearchParams(location.search),
+ [location.search],
+ )
+
+ const routerCtx: RouterContextType = React.useMemo(
+ () => ({
+ Link: AdapterLink,
+ params,
+ pathname: location.pathname,
+ router: {
+ back: () => router.history.back(),
+ forward: () => router.history.forward(),
+ prefetch: (url: string) => router.preloadRoute({ to: url }),
+ push: (url: string) => { void router.navigate({ to: url }) },
+ refresh: () => { void router.invalidate() },
+ replace: (url: string) => { void router.navigate({ to: url, replace: true }) },
+ },
+ searchParams,
+ }),
+ [router, location, params, searchParams],
+ )
+
+ return {children}
+}
+```
+
+**Steps:**
+
+1. Check what `@tanstack/react-router` exports: `useRouter`, `useLocation`, `useParams`, `Link`
+ - Run: `grep -r "export.*useRouter\|export.*useLocation" node_modules/@tanstack/react-router/dist/ 2>/dev/null | head -5` to verify
+2. Replace the file. Note `AdapterLink` is module-level (not nested in component) — this satisfies react-compiler.
+3. Run: `cd packages/tanstack-start && pnpm tsc --noEmit`
+4. Commit: `feat(tanstack-start): implement TanStackRouterProvider with real hooks`
+
+---
+
+### Task 15: Implement cookie helpers and wire adapter
+
+**File:** Update `packages/tanstack-start/src/adapter/index.ts`
+
+```typescript
+import type {
+ AdminAdapterResult,
+ BaseAdminAdapter,
+ CookieOptions,
+} from 'payload'
+
+import { notFound, redirect } from '@tanstack/react-router'
+import { deleteCookie, getCookie, setCookie } from 'vinxi/http'
+import { createAdminAdapter } from 'payload'
+
+import { handleServerFunctions } from '../utilities/handleServerFunctions.js'
+import { initReq } from '../utilities/initReq.js'
+import { TanStackRouterProvider } from './RouterProvider.js'
+
+export function tanstackStartAdapter(): AdminAdapterResult {
+ return {
+ name: 'tanstack-start',
+ init: ({ payload }) =>
+ createAdminAdapter({
+ RouterProvider: TanStackRouterProvider,
+ createRouteHandlers: () => ({}), // Vinxi handles routing via file-system
+ deleteCookie: (name) => deleteCookie(name),
+ getCookie: (name) => getCookie(name),
+ handleServerFunctions,
+ initReq: ({ config, importMap }) => initReq({ config, importMap }),
+ name: 'tanstack-start',
+ notFound: () => {
+ throw notFound()
+ },
+ payload,
+ redirect: (url) => {
+ throw redirect({ to: url })
+ },
+ setCookie: (name, value, options) => setCookie(name, value, options),
+ } satisfies BaseAdminAdapter),
+ }
+}
+```
+
+**Steps:**
+
+1. Replace the file with the above
+2. Verify `vinxi/http` exports `getCookie`, `setCookie`, `deleteCookie` — check: `ls node_modules/vinxi/dist/` and search for cookie exports
+3. Run: `cd packages/tanstack-start && pnpm tsc --noEmit`
+4. Run: `pnpm run build:ui && pnpm run build:next` from repo root — packages/next should still pass
+5. Commit: `feat(tanstack-start): implement full adapter with vinxi/http cookies`
+
+---
+
+### Task 16: Update exports and package.json for tanstack-start
+
+**Files:**
+
+- Update: `packages/tanstack-start/src/index.ts`
+- Update: `packages/tanstack-start/package.json` (add `@tanstack/react-router` and `vinxi` to dependencies or peerDependencies)
+
+```typescript
+// packages/tanstack-start/src/index.ts
+export { tanstackStartAdapter } from './adapter/index.js'
+export { TanStackRouterProvider } from './adapter/RouterProvider.js'
+// Export initReq for users who need direct access
+export { initReq } from './utilities/initReq.js'
+export { handleServerFunctions } from './utilities/handleServerFunctions.js'
+```
+
+```json
+// packages/tanstack-start/package.json additions
+"peerDependencies": {
+ "@tanstack/react-router": ">=1.0.0",
+ "@tanstack/start": ">=1.0.0",
+ "react": "^19.0.0",
+ "vinxi": ">=0.4.0"
+},
+"peerDependenciesMeta": {
+ "@tanstack/react-router": { "optional": false },
+ "vinxi": { "optional": false }
+}
+```
+
+**Steps:**
+
+1. Update both files
+2. Run: `pnpm install`
+3. Run: `pnpm run build:next` — full build check
+4. Commit: `feat(tanstack-start): finalize exports and peer dependencies`
+
+---
+
+### Task 17: Add integration tests for TanStack Start adapter shape
+
+**File:** Create `test/admin-adapter/tanstack-start.spec.ts`
+
+```typescript
+import { describe, expect, it } from 'vitest'
+
+describe('tanstackStartAdapter', () => {
+ it('should export tanstackStartAdapter function', async () => {
+ const { tanstackStartAdapter } = await import('@payloadcms/tanstack-start')
+ expect(typeof tanstackStartAdapter).toBe('function')
+ })
+
+ it('should return AdminAdapterResult with name and init', async () => {
+ const { tanstackStartAdapter } = await import('@payloadcms/tanstack-start')
+ const result = tanstackStartAdapter()
+ expect(result.name).toBe('tanstack-start')
+ expect(typeof result.init).toBe('function')
+ })
+
+ it('should satisfy BaseAdminAdapter interface when init is called', async () => {
+ const { tanstackStartAdapter } = await import('@payloadcms/tanstack-start')
+ const { createAdminAdapter } = await import('payload')
+
+ // Can't call init without full payload instance, but verify the shape
+ const result = tanstackStartAdapter()
+ expect(result.name).toBe('tanstack-start')
+
+ // The adapter factory is the correct type
+ expect(typeof result.init).toBe('function')
+ })
+})
+```
+
+**Steps:**
+
+1. Create the test file
+2. Run: `pnpm run test:int admin-adapter`
+3. Fix any failures
+4. Commit: `test: add TanStack Start adapter shape tests`
+
+---
+
+## Dependency Graph
+
+```
+Phase 1 (Tasks 1-3): Move utilities/elements/templates
+ ↓
+Phase 2 (Tasks 4-6): Move view utilities
+ ↓
+Phase 3 (Tasks 7-9): Move render functions (with nav callbacks)
+ ↓
+Phase 4 (Tasks 10-11): Move handlers + create shared dispatcher
+ ↓
+Phase 5 (Tasks 12-16): TanStack Start adapter implementation
+ ↓
+Task 17: Tests
+```
+
+## Verification at Each Phase
+
+After each phase, run:
+
+```bash
+pnpm run build:ui # packages/ui builds clean
+pnpm run build:next # packages/next builds clean
+pnpm run test:int admin-adapter # adapter tests pass
+```
+
+Final verification:
+
+```bash
+pnpm run dev # dev server starts, admin panel works
+```
+
+## Key Files Summary
+
+**Moved from packages/next → packages/ui:**
+
+- `utilities/selectiveCache.ts`, `getRequestLocale.ts`, `handleAuthRedirect.ts`, `isCustomAdminView.ts`, `isPublicAdminRoute.ts`, `getRouteWithoutAdmin.ts`, `timestamp.ts`, `slugify.ts`
+- `elements/Nav/` (full), `elements/DocumentHeader/` (full)
+- `templates/Default/`, `templates/Minimal/`
+- `views/Document/` utilities (8 files)
+- `views/List/` utilities (8 files)
+- `views/Version/`, `views/Versions/`, `views/Account/` utilities
+- `views/Document/RenderDocument.tsx` (new, accepts nav callbacks)
+- `views/List/RenderListView.tsx` (new, accepts nav callbacks)
+- `views/Document/handleServerFunction.tsx`, `views/List/handleServerFunction.tsx`
+- `utilities/handleServerFunctions.ts` → `utilities/handleServerFunctions.ts` (shared dispatcher)
+
+**New in packages/tanstack-start:**
+
+- `src/utilities/initReq.ts` — uses `getWebRequest()` from `vinxi/http`
+- `src/utilities/handleServerFunctions.ts` — uses shared dispatcher + TanStack navigation
+- `src/adapter/RouterProvider.tsx` — real `@tanstack/react-router` hooks
+- `src/adapter/index.ts` — complete adapter using `vinxi/http` cookies
+
+---
+
+## Phase 6: TanStack Start App Shell + `pnpm dev:tanstack`
+
+This phase creates the TanStack Start equivalent of the auto-generated `app/(payload)/` Next.js files, plus the dev entry point. After this phase `pnpm dev:tanstack` starts a working admin panel.
+
+### Background: How `pnpm dev` works today
+
+```
+pnpm dev
+ → test/dev.ts
+ → loadEnv()
+ → runInit(testSuiteArg) # writes importMap.js, generates DB adapter
+ → nextImport({ dir: rootDir }) # starts Next.js from repo root
+ → serves app/(payload)/ # auto-generated layout.tsx + page.tsx
+```
+
+The auto-generated files wire together:
+
+- `app/(payload)/layout.tsx` → `RootLayout` + `handleServerFunctions` from `@payloadcms/next/layouts`
+- `app/(payload)/admin/[[...segments]]/page.tsx` → `RootPage` from `@payloadcms/next/views`
+- `app/(payload)/api/[...slug]/route.ts` → REST handlers from `@payloadcms/next/routes`
+
+For TanStack Start we need the same wiring using TanStack's routing conventions.
+
+---
+
+### Task 18: Install TanStack Start dependencies
+
+**Files:**
+
+- Modify: `packages/tanstack-start/package.json` — move peer deps to real deps for the workspace app
+- Create: `tanstack-app/package.json`
+
+**Steps:**
+
+1. Create `tanstack-app/package.json`:
+
+```json
+{
+ "name": "payload-tanstack-app",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vinxi dev",
+ "build": "vinxi build",
+ "start": "vinxi start"
+ },
+ "dependencies": {
+ "@payloadcms/tanstack-start": "workspace:*",
+ "@payloadcms/ui": "workspace:*",
+ "@tanstack/react-router": "^1.0.0",
+ "@tanstack/start": "^1.0.0",
+ "payload": "workspace:*",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "vinxi": "^0.4.0"
+ },
+ "devDependencies": {
+ "typescript": "5.x",
+ "vite-tsconfig-paths": "^5.0.0"
+ }
+}
+```
+
+2. Add `tanstack-app` to `pnpm-workspace.yaml`:
+
+```yaml
+packages:
+ - 'packages/*'
+ - 'tanstack-app' # ADD THIS
+ - ...
+```
+
+3. Run: `pnpm install`
+
+4. Verify: `ls node_modules/@tanstack/start` inside `tanstack-app/`
+
+5. Commit: `chore: add tanstack-app workspace package with TanStack Start deps`
+
+---
+
+### Task 19: Create TanStack Start app config (app.config.ts)
+
+TanStack Start uses `app.config.ts` (Vinxi config) instead of `next.config.mjs`.
+
+**File:** Create `tanstack-app/app.config.ts`
+
+```typescript
+import { defineConfig } from '@tanstack/start/config'
+import tsConfigPaths from 'vite-tsconfig-paths'
+
+export default defineConfig({
+ tsr: {
+ appDirectory: './app',
+ },
+ vite: {
+ plugins: [
+ tsConfigPaths({
+ projects: ['./tsconfig.json'],
+ }),
+ ],
+ },
+})
+```
+
+**File:** Create `tanstack-app/tsconfig.json`
+
+```json
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "strict": false,
+ "baseUrl": ".",
+ "paths": {
+ "@payload-config": ["../test/_community/config.ts"]
+ }
+ },
+ "include": ["app/**/*", "app.config.ts"]
+}
+```
+
+The `@payload-config` alias mirrors what Next.js uses — the test suite config is injected via path alias.
+
+**Steps:**
+
+1. Create both files
+2. Run: `cd tanstack-app && pnpm vinxi dev --help` — verify Vinxi is available
+3. Commit: `feat(tanstack-app): add app.config.ts and tsconfig`
+
+---
+
+### Task 20: Create TanStack Start app entry points
+
+TanStack Start requires three entry files.
+
+**File:** Create `tanstack-app/app/client.tsx`
+
+```typescript
+import { StartClient } from '@tanstack/start'
+import { StrictMode } from 'react'
+import { hydrateRoot } from 'react-dom/client'
+import { createRouter } from './router.js'
+
+const router = createRouter()
+
+hydrateRoot(
+ document,
+
+
+ ,
+)
+```
+
+**File:** Create `tanstack-app/app/ssr.tsx`
+
+```typescript
+import {
+ createStartHandler,
+ defaultStreamHandler,
+} from '@tanstack/start/server'
+import { createRouter } from './router.js'
+
+export default createStartHandler({ createRouter })(defaultStreamHandler)
+```
+
+**File:** Create `tanstack-app/app/router.tsx`
+
+```typescript
+import { createRouter as createTanStackRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen.js'
+
+export function createRouter() {
+ return createTanStackRouter({ routeTree })
+}
+
+declare module '@tanstack/react-router' {
+ interface Register {
+ router: ReturnType
+ }
+}
+```
+
+**Note:** `routeTree.gen.ts` is auto-generated by TanStack Router CLI from the routes directory. You need to run `tanstack-router generate` or configure `tsr.watch` in app.config.ts to auto-generate it.
+
+**Steps:**
+
+1. Create all three files
+2. Create a placeholder `tanstack-app/app/routeTree.gen.ts` (will be replaced by codegen):
+ ```typescript
+ // This file is auto-generated by TanStack Router
+ import { rootRoute } from './routes/__root.js'
+ export const routeTree = rootRoute
+ ```
+3. Commit: `feat(tanstack-app): add TanStack Start app entry points`
+
+---
+
+### Task 21: Create the root layout route
+
+The root layout is the TanStack Start equivalent of `app/(payload)/layout.tsx`. It wires in `TanStackRouterProvider` and the Payload `RootLayout`.
+
+**File:** Create `tanstack-app/app/routes/__root.tsx`
+
+```typescript
+import type { ImportMap, ServerFunctionClient } from 'payload'
+
+import config from '@payload-config'
+import { tanstackStartAdapter } from '@payloadcms/tanstack-start'
+import {
+ createRootRoute,
+ HeadContent,
+ Outlet,
+ Scripts,
+} from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/start'
+import React from 'react'
+
+// Import the shared handler dispatcher from packages/ui (available after Phase 4)
+import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions'
+import { initReq } from '@payloadcms/tanstack-start/utilities/initReq'
+
+// Import the generated importMap (written by dev script on startup)
+import { importMap } from '../importMap.js'
+
+// Server function: handles all Payload server function calls
+// This is the TanStack Start equivalent of Next.js's 'use server' function
+const handleServerFn = createServerFn()
+ .validator((data: unknown) => data as Parameters[0])
+ .handler(async ({ data: args }) => {
+ const { cookies, locale, permissions, req } = await initReq({
+ config,
+ importMap,
+ key: 'RootLayout',
+ })
+ return dispatchServerFunction({
+ augmentedArgs: {
+ ...args,
+ cookies,
+ importMap,
+ locale,
+ permissions,
+ req,
+ notFound: () => { throw { _type: 'notFound' } },
+ redirect: (url: string) => { throw { _type: 'redirect', url } },
+ },
+ extraServerFunctions: args.serverFunctions,
+ name: args.name,
+ })
+ })
+
+// Thin wrapper matching Payload's ServerFunctionClient signature
+const serverFunction: ServerFunctionClient = (args) => handleServerFn({ data: args })
+
+export const Route = createRootRoute({
+ component: RootComponent,
+ head: () => ({
+ meta: [{ charSet: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }],
+ }),
+})
+
+// RootLayout from @payloadcms/tanstack-start will be created in packages/tanstack-start/src/layouts/
+// It is the equivalent of RootLayout from @payloadcms/next/layouts, using TanStackRouterProvider
+function RootComponent() {
+ return (
+
+
+
+
+
+ {/* TanStack Start's payload root layout — created in Task 22 */}
+
+
+
+
+
+
+ )
+}
+```
+
+**Note on `PayloadRootLayout`:** This is `RootLayout` from `packages/tanstack-start/src/layouts/Root/`. It is equivalent to `packages/next/src/layouts/Root/index.tsx` but wraps with `TanStackRouterProvider` instead of `NextRouterProvider`. Created in Task 22.
+
+**Steps:**
+
+1. Create the file
+2. Create placeholder `tanstack-app/app/importMap.ts` (will be auto-generated):
+ ```typescript
+ export const importMap = {}
+ ```
+3. Commit: `feat(tanstack-app): add root layout route`
+
+---
+
+### Task 22: Create RootLayout for TanStack Start (in packages/tanstack-start)
+
+This is the TanStack Start equivalent of `packages/next/src/layouts/Root/index.tsx`.
+
+**File:** Create `packages/tanstack-start/src/layouts/Root/index.tsx`
+
+```typescript
+import type { ImportMap, ServerFunctionClient, SanitizedConfig } from 'payload'
+
+import { RootProvider } from '@payloadcms/ui'
+import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
+import { applyLocaleFiltering } from 'payload/shared'
+import React from 'react'
+
+import { TanStackRouterProvider } from '../../adapter/RouterProvider.js'
+import { initReq } from '../../utilities/initReq.js'
+
+type Props = {
+ children: React.ReactNode
+ config: Promise | SanitizedConfig
+ importMap: ImportMap
+ serverFunction: ServerFunctionClient
+}
+
+// Server-side layout: initializes request context and renders Payload RootProvider
+export async function RootLayout({ children, config: configPromise, importMap, serverFunction }: Props) {
+ const {
+ cookies,
+ headers,
+ languageCode,
+ locale,
+ permissions,
+ req,
+ req: { payload: { config } },
+ } = await initReq({ config: configPromise, importMap })
+
+ const theme = cookies.get(`${config.cookiePrefix || 'payload'}-theme`) || 'light'
+
+ // Get client-safe config
+ const clientConfig = getClientConfig({ config, i18n: req.i18n, importMap, user: req.user })
+ await applyLocaleFiltering({ clientConfig, config, req })
+
+ // Language options
+ const languageOptions = Object.entries(config.i18n.supportedLanguages || {}).map(
+ ([value, langConfig]) => ({
+ label: (langConfig as any).translations?.general?.thisLanguage ?? value,
+ value,
+ }),
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+```
+
+**Update** `packages/tanstack-start/src/index.ts` to also export `RootLayout`.
+
+**Steps:**
+
+1. Create the file
+2. Add export to `src/index.ts`
+3. Run: `cd packages/tanstack-start && pnpm tsc --noEmit`
+4. Commit: `feat(tanstack-start): add RootLayout server component`
+
+---
+
+### Task 23: Create admin route and API routes
+
+**File:** Create `tanstack-app/app/routes/admin.$.tsx`
+
+```typescript
+import config from '@payload-config'
+import { createFileRoute } from '@tanstack/react-router'
+import React from 'react'
+
+// The RootPage equivalent for TanStack Start — created in packages/tanstack-start/src/views/Root/
+import { RootPage } from '@payloadcms/tanstack-start/views'
+import { importMap } from '../importMap.js'
+
+export const Route = createFileRoute('/admin/$')({
+ component: AdminPage,
+})
+
+function AdminPage() {
+ const params = Route.useParams()
+ const search = Route.useSearch()
+ // Convert TanStack params to the shape Payload expects
+ const segments = params._splat?.split('/').filter(Boolean) ?? []
+
+ return
+}
+```
+
+**File:** Create `tanstack-app/app/routes/api.$.ts` (API catch-all for REST)
+
+```typescript
+import config from '@payload-config'
+import { createAPIFileRoute } from '@tanstack/start/api'
+import {
+ REST_DELETE,
+ REST_GET,
+ REST_OPTIONS,
+ REST_PATCH,
+ REST_POST,
+} from '@payloadcms/tanstack-start/routes'
+
+export const APIRoute = createAPIFileRoute('/api/$')({
+ GET: ({ request }) => REST_GET(config)(request),
+ POST: ({ request }) => REST_POST(config)(request),
+ DELETE: ({ request }) => REST_DELETE(config)(request),
+ PATCH: ({ request }) => REST_PATCH(config)(request),
+ OPTIONS: ({ request }) => REST_OPTIONS(config)(request),
+})
+```
+
+**Note:** `@payloadcms/tanstack-start/views` and `@payloadcms/tanstack-start/routes` require new exports:
+
+- `RootPage` — the TanStack Start admin page renderer (see Task 24)
+- REST route handlers adapted from `@payloadcms/next/routes` (see Task 25)
+
+**Steps:**
+
+1. Create both files
+2. Commit: `feat(tanstack-app): add admin and API routes`
+
+---
+
+### Task 24: Create RootPage and route handlers for TanStack Start
+
+**File:** Create `packages/tanstack-start/src/views/Root/index.tsx`
+
+This is the TanStack Start equivalent of `packages/next/src/views/Root/index.tsx` — it:
+
+- Calls `initReq` using vinxi/http
+- Routes to the correct view based on the URL segments
+- Throws TanStack's `notFound()`/`redirect()` instead of Next.js's
+
+```typescript
+import type { ImportMap, SanitizedConfig } from 'payload'
+
+import config from '@payload-config'
+import { notFound, redirect } from '@tanstack/react-router'
+import React from 'react'
+
+// Reuse the shared RootPage logic from packages/ui once it's moved (Phase 3)
+// For now, replicate the core routing logic from packages/next/src/views/Root/index.tsx
+// replacing notFound/redirect with TanStack's versions
+
+import { importMap } from '../../../importMap.js' // relative to tanstack-app when bundled
+
+type Props = {
+ config: Promise | SanitizedConfig
+ importMap: ImportMap
+ searchParams: Record
+ segments: string[]
+}
+
+export async function RootPage({
+ config: configPromise,
+ importMap,
+ segments,
+ searchParams,
+}: Props) {
+ // Import the shared Root view logic from packages/ui/views/Root
+ // (which will accept notFound/redirect as callbacks after Phase 3 of this plan)
+ const { renderRootPage } = await import(
+ '@payloadcms/ui/views/Root/RenderRoot'
+ )
+
+ return renderRootPage({
+ config: configPromise,
+ importMap,
+ notFound: () => {
+ throw notFound()
+ },
+ redirect: (url) => {
+ throw redirect({ to: url })
+ },
+ searchParams,
+ segments,
+ })
+}
+```
+
+**File:** Create `packages/tanstack-start/src/routes/index.ts`
+
+```typescript
+// REST route handlers adapted for TanStack Start's API route format
+// They accept Request objects (same Web API) so we can delegate to the
+// framework-agnostic REST handlers.
+export {
+ REST_DELETE,
+ REST_GET,
+ REST_OPTIONS,
+ REST_PATCH,
+ REST_POST,
+} from '@payloadcms/next/routes'
+// Note: Until packages/next REST routes are decoupled, we re-export them.
+// The REST handlers use standard Request/Response Web APIs and don't
+// depend on Next.js internals — they work in any runtime.
+```
+
+**Update** `packages/tanstack-start/package.json` exports:
+
+```json
+"./views": { "import": "./src/views/index.ts", ... },
+"./routes": { "import": "./src/routes/index.ts", ... }
+```
+
+**Steps:**
+
+1. Create `RootPage` (initially delegating to the shared Root render after Phase 3)
+2. Create routes index re-exporting from `@payloadcms/next/routes`
+3. Update package.json exports
+4. Commit: `feat(tanstack-start): add RootPage and route exports`
+
+---
+
+### Task 25: Create dev-tanstack.ts entry point
+
+**File:** Create `test/dev-tanstack.ts`
+
+```typescript
+import chalk from 'chalk'
+import execa from 'execa'
+import minimist from 'minimist'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { loadEnv } from 'payload/node'
+
+import { runInit } from './runInit.js'
+
+loadEnv()
+
+const filename = fileURLToPath(import.meta.url)
+const dirname = path.dirname(filename)
+
+const {
+ _: [_testSuiteArg = '_community'],
+} = minimist(process.argv.slice(2))
+
+const testSuiteArg = _testSuiteArg as string
+
+console.log(`Selected test suite: ${testSuiteArg} [TanStack Start / Vinxi]`)
+
+// Generate importMap and DB adapter (reuses the same init logic as Next.js dev)
+// This writes to tanstack-app/app/importMap.js instead of app/(payload)/admin/importMap.js
+process.env.ROOT_DIR = path.resolve(dirname, '..', 'tanstack-app')
+
+await runInit(testSuiteArg, true, false)
+
+// Start Vinxi dev server
+const tanstackAppDir = path.resolve(dirname, '..', 'tanstack-app')
+const port = process.env.PORT ? Number(process.env.PORT) : 3100 // Use 3100 to avoid conflict with Next.js dev
+
+const vinxiProcess = execa('pnpm', ['vinxi', 'dev', '--port', String(port)], {
+ cwd: tanstackAppDir,
+ stdio: 'inherit',
+ env: {
+ ...process.env,
+ PORT: String(port),
+ },
+})
+
+console.log(chalk.green(`✓ TanStack Start dev server starting on port ${port}`))
+console.log(chalk.cyan(` Admin: http://localhost:${port}/admin`))
+
+// Forward signals
+process.on('SIGINT', () => {
+ vinxiProcess.kill('SIGINT')
+ process.exit(0)
+})
+process.on('SIGTERM', () => {
+ vinxiProcess.kill('SIGTERM')
+ process.exit(0)
+})
+
+await vinxiProcess
+```
+
+**Root package.json** — add the script:
+
+```json
+"dev:tanstack": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=16384\" tsx ./test/dev-tanstack.ts",
+"dev:tanstack:postgres": "cross-env PAYLOAD_DATABASE=postgres pnpm runts ./test/dev-tanstack.ts"
+```
+
+**Also update `initDevAndTest.ts`** to support writing the importMap to the TanStack app location when `ROOT_DIR` points there:
+
+- Currently hardcodes `./app/(payload)/admin/importMap.js` — make this path configurable
+- Change: `path.resolve(process.env.ROOT_DIR || getNextRootDir(testSuiteArg).rootDir, './app/(payload)/admin/importMap.js')`
+ to a helper that checks whether `ROOT_DIR` contains a TanStack app and picks the right path
+
+**Steps:**
+
+1. Create `test/dev-tanstack.ts`
+2. Add scripts to root `package.json`
+3. Update `initDevAndTest.ts` to write importMap to `${ROOT_DIR}/app/importMap.js` when the TanStack app is the target (check by presence of `app.config.ts`)
+4. Run: `pnpm dev:tanstack` — expect Vinxi to start
+5. Fix any import/build errors
+6. Commit: `feat: add pnpm dev:tanstack command and dev entry point`
+
+---
+
+### Task 26: Smoke test `pnpm dev:tanstack`
+
+**Steps:**
+
+1. Run: `pnpm dev:tanstack`
+2. Navigate to: `http://localhost:3100/admin`
+3. Verify: admin panel loads, can log in, can navigate collections
+4. Run: `curl -s http://localhost:3100/api/access | jq .` — verify REST API responds
+5. Fix any runtime errors
+6. Commit: `test: verify pnpm dev:tanstack works end-to-end`
+
+---
+
+## Updated Dependency Graph
+
+```
+Phase 1 (Tasks 1-3): Move utilities/elements/templates
+ ↓
+Phase 2 (Tasks 4-6): Move view utilities
+ ↓
+Phase 3 (Tasks 7-9): Move render functions (with nav callbacks)
+ ↓
+Phase 4 (Tasks 10-11): Move handlers + create shared dispatcher
+ ↓
+Phase 5 (Tasks 12-16): TanStack Start adapter implementation
+ ↓
+Phase 6 (Tasks 18-26): TanStack Start app shell + pnpm dev:tanstack
+ ↓
+Task 17 + Task 26: Integration tests + smoke test
+```
+
+## File Summary — Phase 6 New Files
+
+```
+tanstack-app/
+├── app.config.ts # Vinxi/TanStack Start config
+├── tsconfig.json # Path aliases (especially @payload-config)
+├── package.json # TanStack deps
+└── app/
+ ├── client.tsx # Hydration entry
+ ├── ssr.tsx # SSR entry
+ ├── router.tsx # Router definition
+ ├── importMap.ts # Auto-generated on dev start
+ ├── routeTree.gen.ts # Auto-generated by TanStack Router
+ └── routes/
+ ├── __root.tsx # Root layout (uses RootLayout from packages/tanstack-start)
+ ├── admin.$.tsx # Admin catch-all → RootPage
+ └── api.$.ts # REST API catch-all
+
+packages/tanstack-start/src/
+├── layouts/
+│ └── Root/
+│ └── index.tsx # RootLayout server component
+├── views/
+│ └── Root/
+│ └── index.tsx # RootPage (route renderer)
+└── routes/
+ └── index.ts # Re-exports REST handlers
+
+test/
+└── dev-tanstack.ts # pnpm dev:tanstack entry point
+```
diff --git a/docs/plans/2026-04-02-data-first-view-loading.md b/docs/plans/2026-04-02-data-first-view-loading.md
new file mode 100644
index 00000000000..9c7190f85a3
--- /dev/null
+++ b/docs/plans/2026-04-02-data-first-view-loading.md
@@ -0,0 +1,219 @@
+# Data-First View Loading
+
+## Goal
+
+Replace the fragile `useEffect`-based server function pattern in TanStack view adapters with loader-based data fetching, and extract shared data-fetching functions so both Next RSC and TanStack loaders can fetch data before rendering.
+
+## The Problem
+
+The current TanStack adapters for List, Document, BrowseByFolder, and CollectionFolders all follow this pattern:
+
+```mermaid
+sequenceDiagram
+ participant Loader as TanStack Loader
+ participant Comp as Client Component
+ participant SF as Server Function
+
+ Loader->>Comp: pageState (metadata only)
+ Note over Comp: Renders LoadingOverlay
+ Comp->>SF: useEffect → serverFunction("render-list")
+ SF-->>Comp: { List: React.ReactNode }
+ Note over Comp: setState → re-render with JSX
+```
+
+This is unreliable:
+
+- **Waterfall**: loader fetches metadata, component mounts, THEN fetches actual view content
+- **Flash of loading**: user always sees `LoadingOverlay` before content
+- **Race conditions**: fast navigation can cause stale state updates (mitigated by `cancelled` flag, but fragile)
+- **Serialization concern**: shipping pre-rendered `React.ReactNode` over a server function POST is opaque and hard to debug
+
+The Dashboard already works correctly — `getPageState` fetches dashboard data in the loader, and the component renders immediately with data. Auth views work the same way. List/Document/Folder views should follow the same pattern.
+
+## Target Architecture
+
+```mermaid
+sequenceDiagram
+ participant Loader as TanStack Loader / Next RSC
+ participant DataFn as Shared Data Function
+ participant Comp as Shared UI Component
+
+ Loader->>DataFn: fetchListData(req, collection, query)
+ DataFn-->>Loader: { docs, columns, permissions, ... }
+ Loader->>Comp: Pass data as props
+ Note over Comp: Renders immediately — no loading state
+```
+
+Both frameworks call the same shared data-fetching function, then pass data to the same shared component:
+
+- **Next**: RSC calls `fetchListData()` → passes data to ``
+- **TanStack**: Loader calls `fetchListData()` → serializes as JSON → component receives via `useLoaderData()` → passes to same ``
+
+The shared component is a regular React client component. No async, no server function, no useEffect for initial data.
+
+## Key Constraint: Serializable Data, Not JSX
+
+The current builders (`renderListView`, `renderDocument`) return `React.ReactNode` — pre-rendered JSX that includes providers, slot components, tables, etc. This cannot be JSON-serialized through a TanStack loader.
+
+The fix is to split each builder into two layers:
+
+1. **Data function** (shared, framework-agnostic) — fetches and returns plain serializable data
+2. **Render function** (shared, React component) — accepts data props, renders JSX
+
+The existing builders can be refactored to compose these two layers internally, so the `renderListView` API doesn't break.
+
+## Phased Approach
+
+### Phase 0: Inventory what each view needs as data
+
+Before coding, identify the minimum serializable data each view needs. This determines the shape of the new data functions.
+
+**List view** (from `renderListView` internals):
+
+- `docs: PaginatedDocs` — the query result
+- `columnState: Column[]` — resolved column definitions
+- `hasCreatePermission, hasDeletePermission, hasTrashPermission: boolean`
+- `newDocumentURL: string`
+- `collectionSlug: string`
+- `queryPreset, resolvedFilterOptions` — filter/preset data
+- Collection preferences
+
+**Document view** (from `renderDocument` internals):
+
+- `doc: Data` — the document record (already returned as `data`)
+- `formState: FormState` — serialized field state
+- `docPermissions: SanitizedDocumentPermissions`
+- `docPreferences`
+- `isLocked: boolean`
+- `versions` data
+- Collection/global config reference
+
+**Folder views**: Similar to List — paginated results + folder hierarchy data.
+
+### Phase 1: Extract data-fetching functions from builders
+
+For each view, extract a `fetchXxxViewData()` function from the existing builder. These functions take `req` + view-specific args and return plain serializable data.
+
+**Files to create/modify:**
+
+- `packages/ui/src/views/List/fetchListViewData.ts` — new, extracted from `packages/ui/src/views/List/RenderListView.tsx` lines 81-350 (the data-fetching portion before JSX assembly)
+- `packages/ui/src/views/Document/fetchDocumentViewData.ts` — new, extracted from `packages/ui/src/views/Document/RenderDocument.tsx`
+- `packages/ui/src/views/BrowseByFolder/fetchBrowseFolderData.ts` — new
+- `packages/ui/src/views/CollectionFolders/fetchCollectionFolderData.ts` — new
+
+Each returns a typed, JSON-serializable data object. No React nodes.
+
+Refactor the existing builders to call these functions internally:
+
+```ts
+renderListView(args) {
+ const data = await fetchListViewData(args) // extracted
+ return { List: } // existing JSX assembly
+}
+```
+
+This is a non-breaking refactor — the existing `renderListView` API and Next adapters continue to work unchanged.
+
+### Phase 2: Create shared client view components that accept data props
+
+For each view, create (or adapt) a client component that accepts the data object from Phase 1 and renders the view.
+
+**Key files:**
+
+- The List view's `DefaultListView` (`packages/ui/src/views/List/index.tsx`) already accepts `ListViewClientProps` but also expects pre-built JSX in slots (`Table`, `renderedFilters`). The Table and filters would need to be built client-side from data.
+- The Document view's `EditView` and `DocumentInfoProvider` already handle most rendering from data props. The gap is `formState` and slot resolution.
+- The Dashboard's `ModularDashboardClient` already works this way — it is the reference.
+
+This is the hardest phase. The main challenge is that some props are currently `React.ReactNode` (pre-rendered server-side):
+
+- `Table` in ListViewSlots
+- `renderedFilters` in List
+- Document slots (Save, Publish, Preview buttons)
+
+These would need to move to client-side rendering from data/config, or use server functions for on-demand rendering (like `RenderWidget` already does for dashboard widgets).
+
+### Phase 3: Move data fetching into TanStack loader via `getPageState`
+
+Extend `packages/tanstack-start/src/views/Root/getPageState.ts` to call the new data functions for each view type, just like it already does for `dashboard`:
+
+```ts
+// In getPageState, after determining viewType:
+if (pageViewType === 'list') {
+ pageData = {
+ ...pageData,
+ list: await fetchListViewData({ req, collectionSlug, query, ... }),
+ }
+}
+
+if (pageViewType === 'document') {
+ pageData = {
+ ...pageData,
+ document: await fetchDocumentViewData({ req, collectionSlug, docID, ... }),
+ }
+}
+```
+
+Since `getPageState` runs in the TanStack loader, data is available immediately when the component mounts — no useEffect, no loading spinner, no waterfall.
+
+### Phase 4: Rewrite TanStack view adapters to use loader data
+
+Replace the `useEffect` + `serverFunction` pattern with direct data consumption:
+
+```tsx
+// Before (current):
+function ListView({ pageState }) {
+ const [listView, setListView] = useState(null)
+ useEffect(() => {
+ serverFunction({ name: 'render-list', args: ... })
+ .then(result => setListView(result.List))
+ }, [])
+ return isLoading ? : listView
+}
+
+// After (target):
+function ListView({ pageState }) {
+ const listData = pageState.pageData.list
+ return
+}
+```
+
+**Files to modify:**
+
+- `packages/tanstack-start/src/views/List/index.tsx`
+- `packages/tanstack-start/src/views/Document/index.tsx`
+- `packages/tanstack-start/src/views/BrowseByFolder/index.tsx`
+- `packages/tanstack-start/src/views/CollectionFolders/index.tsx`
+- `packages/tanstack-start/src/views/Account/index.tsx`
+- `packages/tanstack-start/src/views/Root/types.ts` — extend `SerializablePageData` with list/document/folder data types
+
+### Phase 5: Align Next adapters to use the same data functions
+
+Next adapters can optionally be refactored to call the same `fetchXxxViewData()` functions, making the data-fetching truly shared:
+
+```tsx
+// packages/next/src/views/List/index.tsx
+export const ListView = async (args) => {
+ const data = await fetchListViewData(args)
+ return
+}
+```
+
+This is optional for Phase 1 since Next's current RSC pattern already works. But it aligns the architecture: both frameworks call the same data function, pass to the same component.
+
+## Recommended Order
+
+1. **Start with List** — it's the most commonly used view after Dashboard and has well-understood data needs
+2. **Then Document** — more complex due to form state, but high impact
+3. **Then Folder views** — similar to List
+4. **Account last** — smallest scope
+
+## The Hard Part: Table and Slots as JSX
+
+The biggest obstacle is that `renderListView` currently builds `Table` as pre-rendered JSX server-side (with `renderTable()`). The table includes resolved cell components from the import map.
+
+Two approaches:
+
+- **Option A**: Render table client-side from column definitions + data. The `Table` component would need to resolve cell renderers client-side. This is the cleaner architecture but more work.
+- **Option B**: Keep using `RenderWidget`-style server functions for table/slot rendering on demand. The initial data (docs, columns, permissions) loads in the loader, but the table body fetches via server function after mount. This is a hybrid — eliminates the main waterfall while deferring heavy JSX resolution.
+
+The Dashboard already uses Option B for widgets (`RenderWidget` calls `render-widget` server function). This is a proven pattern in the codebase.
diff --git a/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md b/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md
new file mode 100644
index 00000000000..bd66b64bdcb
--- /dev/null
+++ b/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md
@@ -0,0 +1,488 @@
+# TanStack Start Client Rendering Implementation Plan
+
+## Goal
+
+Replace the current TanStack Start admin page path, which renders the server-side `RootPage`, with:
+
+1. A serializable server-side page-state function
+2. A client-only TanStack admin page shell
+3. Client-side view wrappers that reuse existing client UI pieces from `packages/ui`
+
+This removes the need for `serverOnlyStubPlugin` in the TanStack app while leaving the Next.js adapter untouched.
+
+## Verified Current State
+
+These points are based on the current repo, not assumptions:
+
+- [tanstack-app/app/routes/admin.$.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/admin.$.tsx) still returns `segments` from the loader and renders `RootPage`.
+- [packages/tanstack-start/src/views/Root/index.tsx](/Users/orakhmatulin/work/payload/packages/tanstack-start/src/views/Root/index.tsx) imports `@payloadcms/ui/views/Root/RenderRoot`, which keeps TanStack on the RSC path.
+- [packages/tanstack-start/src/layouts/Root/index.tsx](/Users/orakhmatulin/work/payload/packages/tanstack-start/src/layouts/Root/index.tsx) already mounts `RootProvider`, `TanStackRouterProvider`, auth, translations, config, and server functions. The new page path must fit inside this provider tree.
+- There is no existing TanStack-specific client page/view layer in `packages/tanstack-start/src/views` beyond the server `RootPage`.
+- Existing reusable client view pieces already live in `packages/ui`, notably:
+ - [packages/ui/src/views/List/index.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/views/List/index.tsx)
+ - [packages/ui/src/views/Edit/index.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/views/Edit/index.tsx)
+ - [packages/ui/src/views/CreateFirstUser/index.client.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/views/CreateFirstUser/index.client.tsx)
+ - [packages/ui/src/views/Dashboard/Default/ModularDashboard/index.client.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/views/Dashboard/Default/ModularDashboard/index.client.tsx)
+- Existing server functions `render-document` and `render-list` still return React nodes:
+ - [packages/ui/src/views/Document/handleServerFunction.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/views/Document/handleServerFunction.tsx)
+ - [packages/ui/src/views/List/handleServerFunction.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/views/List/handleServerFunction.tsx)
+- The current default template/nav path is server-oriented:
+ - [packages/ui/src/templates/Default/index.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/templates/Default/index.tsx)
+ - [packages/ui/src/elements/Nav/index.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/elements/Nav/index.tsx)
+
+## Constraints
+
+- Do not modify `packages/next`.
+- Keep Next.js behavior unchanged.
+- Minimize changes in `packages/ui`.
+- A small `packages/ui` exception is acceptable where required for serialization support. The main justified example is extracting a serializable `buildTableData`.
+- Do not attempt to serialize React nodes across TanStack server functions.
+- Keep `RootLayout` and `RootProvider` as the shared TanStack layout foundation.
+
+## Architecture
+
+### Next.js path
+
+Unchanged. It continues to use:
+
+- `renderRootPage`
+- Server-rendered templates
+- Server-rendered view entry points
+- Existing `render-document` and `render-list` behavior
+
+### TanStack Start path
+
+New flow:
+
+1. Route loader calls `getPageState`
+2. `getPageState` runs server-side and returns only serializable data
+3. Route component renders a client-only `TanStackAdminPage`
+4. `TanStackAdminPage` picks a built-in client wrapper by `viewType`
+5. Client wrappers reuse existing client components from `packages/ui`
+6. Any view-specific mutations that currently depend on `render-document` / `render-list` trigger route invalidation instead of returning React nodes
+
+### Important scope correction
+
+The implementation should not try to make the existing `packages/ui` server view entry points directly serializable. Instead:
+
+- Keep those server entry points for Next.js
+- Add TanStack-specific client wrappers in `packages/tanstack-start`
+- Reuse existing client components from `packages/ui` where they already exist
+
+## Serializable Page State
+
+Create a new server-side page-state function in:
+
+- `packages/tanstack-start/src/views/Root/getPageState.ts`
+
+Responsibilities:
+
+- Call `initReq`
+- Recreate the route-resolution logic currently buried in `renderRootPage`
+- Handle redirects and notFound via TanStack Router errors
+- Return only serializable data
+- Recompute any page-level data the client shell cannot derive from context alone
+
+### Required fields
+
+The page state should include at least:
+
+```ts
+type SerializablePageState = {
+ browseByFolderSlugs: string[]
+ clientConfig: ClientConfig
+ documentSubViewType?: DocumentSubViewTypes
+ locale?: Locale
+ permissions: SanitizedPermissions
+ routeParams: {
+ collection?: string
+ folderCollection?: string
+ folderID?: number | string
+ global?: string
+ id?: number | string
+ token?: string
+ versionID?: number | string
+ }
+ searchParams?: Record
+ segments: string[]
+ templateClassName: string
+ templateType?: 'default' | 'minimal'
+ viewType?: ViewTypes
+ visibleEntities: VisibleEntities
+ navPreferences?: NavPreferences
+ customView?: PayloadComponent | undefined
+ viewActions?: PayloadComponent[]
+}
+```
+
+### Serialization rules
+
+- Built-in views should be represented by `viewType`, not by a React function.
+- `viewActions` should only include import-map-addressable payload components.
+- Any direct React function references must be dropped from the serialized output.
+- `visibleEntities` must be present unless the implementation instead serializes fully prepared nav groups.
+
+### Auth and route handling
+
+Move the route/auth checks into `getPageState` so the route loader becomes thin.
+
+Reuse logic from:
+
+- [packages/ui/src/views/Root/getRouteData.ts](/Users/orakhmatulin/work/payload/packages/ui/src/views/Root/getRouteData.ts)
+- [packages/ui/src/utilities/handleAuthRedirect.ts](/Users/orakhmatulin/work/payload/packages/ui/src/utilities/handleAuthRedirect.ts)
+- [packages/ui/src/utilities/isPublicAdminRoute.ts](/Users/orakhmatulin/work/payload/packages/ui/src/utilities/isPublicAdminRoute.ts)
+- [packages/ui/src/utilities/isCustomAdminView.ts](/Users/orakhmatulin/work/payload/packages/ui/src/utilities/isCustomAdminView.ts)
+
+Do not call `renderRootPage` from `getPageState`.
+
+## Route Rewrite
+
+Replace the current route flow in:
+
+- `tanstack-app/app/routes/admin.$.tsx`
+
+Current:
+
+- Loader returns `{ segments }`
+- Component renders ``
+
+Target:
+
+- Loader calls `getPageState`
+- Component renders ``
+
+The route should pass `search` from TanStack Router into the client page as plain search params.
+
+## Client Page Shell
+
+Create:
+
+- `packages/tanstack-start/src/views/Root/TanStackAdminPage.tsx`
+
+Requirements:
+
+- `'use client'`
+- Must not import server-only modules
+- Must sit inside the existing layout/provider tree from `RootLayout`
+- Should use `PageConfigProvider`, not replace `RootProvider`
+
+Responsibilities:
+
+1. Apply page-level config shadowing with `PageConfigProvider`
+2. Pick template by `templateType`
+3. Pick the built-in TanStack view wrapper by `viewType`
+4. Resolve import-map based custom views if they are safe client components
+5. Pass route/search/page state into the selected client wrapper
+
+## Template Strategy
+
+Do not try to render [packages/ui/src/templates/Default/index.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/templates/Default/index.tsx) directly in the client path. It depends on server-side `RenderServerComponent`, `payload`, and server props.
+
+Instead create TanStack-safe client templates in `packages/tanstack-start`, likely:
+
+- `packages/tanstack-start/src/templates/Default/TanStackDefaultTemplate.tsx`
+- `packages/tanstack-start/src/templates/Minimal/TanStackMinimalTemplate.tsx`
+
+These should reuse client-safe UI pieces from `packages/ui` where possible.
+
+### Default template requirements
+
+- Use `visibleEntities` and/or precomputed nav groups
+- Use `DefaultNavClient` instead of `DefaultNav`
+- Preserve core layout structure, app header behavior, nav toggling, and wrappers where client-safe
+- Avoid `RenderServerComponent`
+
+### Minimal template requirements
+
+- Mirror the minimal shell used for login/reset/verify-like routes
+- Avoid any server component hooks
+
+## View Wrapper Strategy
+
+Add TanStack-specific client wrappers under `packages/tanstack-start/src/views`.
+
+These wrappers should map built-in `viewType` values to existing client functionality in `packages/ui`.
+
+### Views that can reuse existing client components
+
+- `list`
+ - Reuse `DefaultListView`
+- `document`
+ - Reuse `DefaultEditView` and surrounding client providers required by the edit path
+- `createFirstUser`
+ - Reuse `CreateFirstUserClient`
+- `account`
+ - Reuse `AccountClient`
+- Dashboard internals
+ - Reuse the modular dashboard client pieces where feasible
+
+### Views that still need server-derived state
+
+If a view currently has only a server entry point, add a thin TanStack client wrapper that:
+
+- fetches serializable state from server functions on mount, or
+- consumes state already prepared by `getPageState`
+
+Do not pass server-rendered React nodes through page state.
+
+### Custom views
+
+Support only import-map-addressable custom views in the serialized route state.
+
+Rules:
+
+- If `PayloadComponent` is a path/object reference, resolve it through the import map on the client.
+- If the current route resolves to a direct React function that is not safely serializable, do not attempt to pass it through page state.
+- If needed, document that TanStack custom views require import-map-addressable components for this path.
+
+## List View Data
+
+The current plan to extract `buildTableData` is valid, but it needs one correction:
+
+- It should be framed as a small, deliberate `packages/ui` exception to support a serializable TanStack path.
+
+### New utility
+
+Add:
+
+- `packages/ui/src/utilities/buildTableData.ts`
+
+Export it from:
+
+- `packages/ui/src/exports/utilities.ts`
+
+### Behavior
+
+This helper should:
+
+- Share the data-fetching and column-building logic from `buildTableState`
+- Return only serializable data:
+ - `columns`
+ - `data`
+ - `preferences`
+- Not render:
+ - `Table`
+ - `renderedFilters`
+
+### Consistency requirement
+
+Do not introduce a TanStack-only behavior fork in the shared fetch logic.
+
+Note:
+
+- [packages/ui/src/utilities/buildTableState.ts](/Users/orakhmatulin/work/payload/packages/ui/src/utilities/buildTableState.ts) currently contains a `joinQuery.page` / `joinQuery.limit` inconsistency in the array-of-collections branch.
+
+If that logic is touched, either:
+
+- preserve the current behavior exactly in both paths, or
+- fix the bug intentionally for both shared consumers
+
+Do not silently make TanStack behave differently from Next.js.
+
+## List Wrapper
+
+Add a TanStack list wrapper that:
+
+1. Obtains serializable list data
+2. Builds client-safe list props
+3. Reuses `DefaultListView`
+
+Prefer:
+
+- server-side serializable data from `buildTableData`
+- client-side `ListQueryProvider`
+- client-side `TableColumnsProvider`
+- client-side filters/table behavior only where the underlying props are serializable
+
+Do not depend on `renderListView` or `render-list` for page rendering.
+
+## Document Wrapper
+
+Add a TanStack document wrapper that:
+
+1. Fetches or derives serializable document state
+2. Reuses `DefaultEditView`
+3. Provides the same client providers the edit view expects
+
+The wrapper must not rely on `renderDocument` returning a React node.
+
+Reuse existing document-related server utilities where possible for data, permissions, preferences, lock state, and form state, but return plain data only.
+
+## Dashboard Wrapper
+
+The dashboard path needs special care because the existing server view still resolves server-rendered content around modular dashboard widgets.
+
+Recommended approach:
+
+1. Start with a minimal TanStack dashboard wrapper that renders the core dashboard client shell and widget area without server-only imports.
+2. Reuse the modular dashboard client pieces from `packages/ui`.
+3. If some dashboard extensions remain server-only, explicitly leave them unsupported in the first TanStack slice rather than sneaking server imports back into the client tree.
+
+The first implementation should optimize for removing the stub plugin and keeping the default dashboard functional.
+
+## Server Function Invalidation
+
+Keep the existing shared dispatcher in `packages/ui` intact.
+
+Change TanStack-specific handling in:
+
+- `packages/tanstack-start/src/utilities/handleServerFunctions.ts`
+
+### Required behavior
+
+Intercept:
+
+- `render-document`
+- `render-list`
+
+Return a TanStack-specific invalidation sentinel, for example:
+
+```ts
+{
+ __tanstack_invalidate: true
+}
+```
+
+All other server functions should continue to dispatch normally through `dispatchServerFunction`.
+
+## Router Provider Invalidation Hook
+
+Update:
+
+- `packages/tanstack-start/src/adapter/RouterProvider.tsx`
+
+So that the client-side router/server-function flow recognizes the TanStack invalidation sentinel and calls:
+
+- `router.invalidate()`
+
+This should refresh route data and allow the client page shell to rebuild from fresh serialized page state.
+
+## Export Surface
+
+Update TanStack exports as needed so the new view/page helpers are available from:
+
+- `packages/tanstack-start/src/index.ts`
+
+If `RootPage` is removed or superseded, update the export surface accordingly and adjust the route import sites.
+
+## Vite Cleanup
+
+After the new page path is fully client-safe, remove:
+
+- `serverOnlyStubPlugin`
+
+from:
+
+- [tanstack-app/vite.config.ts](/Users/orakhmatulin/work/payload/tanstack-app/vite.config.ts)
+
+Keep:
+
+- `tanstackStartCompatPlugin`
+
+Do not remove the stub plugin before the new route path is verified to build and hydrate cleanly.
+
+## Implementation Order
+
+Implement in this order:
+
+### Phase 0
+
+- Rewrite the TanStack plan and align it with repo reality
+
+### Phase 1
+
+- Add `buildTableData` in `packages/ui`
+- Export it
+- Typecheck `packages/ui`
+
+### Phase 2
+
+- Add `getPageState`
+- Move route/auth/page-state logic into it
+- Add tests for the serialized page-state shape
+
+### Phase 3
+
+- Replace `RootPage` route usage with `TanStackAdminPage`
+- Keep the first shell minimal but functional
+- Verify there are no server-only imports in the page client graph
+
+### Phase 4
+
+- Add TanStack-safe default/minimal templates
+- Add nav handling using client-safe pieces
+
+### Phase 5
+
+- Add built-in client wrappers for:
+ - list
+ - document
+ - dashboard
+ - account
+ - createFirstUser
+ - remaining simple auth/minimal views as needed
+
+### Phase 6
+
+- Add TanStack invalidation handling for `render-document` and `render-list`
+- Wire router invalidation in the TanStack adapter/provider
+
+### Phase 7
+
+- Remove `serverOnlyStubPlugin`
+- Run targeted tests/build verification
+
+## Testing
+
+### Integration
+
+Expand:
+
+- `test/admin-adapter/tanstack-start.int.spec.ts`
+
+Add coverage for:
+
+- `getPageState` exports and returns a plain serializable object
+- built-in view types resolve to expected serializable state
+- auth redirect/notFound behavior is preserved
+- TanStack `handleServerFunctions` returns invalidation for:
+ - `render-document`
+ - `render-list`
+- other server functions still dispatch normally
+
+### E2E
+
+Add or extend TanStack app coverage in the appropriate test location for:
+
+- dashboard loads without server-only import/runtime errors
+- list view loads and navigates
+- document view loads and saves
+- client-side navigation between admin routes works
+- document/list mutations trigger route invalidation and refresh
+- browser console does not show server-only module failures
+
+### Manual verification
+
+At minimum verify:
+
+1. TanStack app builds without relying on `serverOnlyStubPlugin`
+2. Admin root route loads
+3. List route loads
+4. Document route loads
+5. Save/delete flows refresh correctly
+
+## Non-Goals
+
+- Reworking the Next.js adapter
+- Converting all existing `packages/ui` server views into dual server/client implementations
+- Full parity for every custom server-rendered extension on day one if that would reintroduce server-only imports into the TanStack client graph
+
+## Deliverable
+
+The final implementation is successful when:
+
+- `tanstack-app/app/routes/admin.$.tsx` no longer renders `RootPage`
+- TanStack admin rendering no longer imports `RenderRoot` into the page path
+- the TanStack app works without `serverOnlyStubPlugin`
+- Next.js behavior remains unchanged
+- the new plan is accurately reflected in code and tests
diff --git a/docs/plans/2026-04-02-tanstack-start-client-rendering.md b/docs/plans/2026-04-02-tanstack-start-client-rendering.md
new file mode 100644
index 00000000000..451385d94f6
--- /dev/null
+++ b/docs/plans/2026-04-02-tanstack-start-client-rendering.md
@@ -0,0 +1,376 @@
+# TanStack Start: Serializable Page State + Client Rendering
+
+## Goal
+
+Fix the TanStack Start admin rendering path by removing the dependency on `renderRootPage` and replacing it with:
+
+- a serializable server-side page-state function
+- a client-only TanStack admin page shell
+- TanStack-specific client view wrappers that reuse existing client UI pieces from `packages/ui`
+
+Next.js remains unchanged and continues using the existing RSC-based admin rendering path.
+
+## Problem
+
+TanStack Start is isomorphic by default. The current admin route still renders `RootPage`, and `RootPage` imports `@payloadcms/ui/views/Root/RenderRoot`. That pulls the server-rendered admin path into the TanStack route graph.
+
+Because `renderRootPage` and the current server view/template chain transitively depend on server-only modules, TanStack’s client build currently needs `serverOnlyStubPlugin` in `tanstack-app/vite.config.ts`.
+
+That plugin is only masking the real issue. Each new server-only dependency can create a new browser-bundle breakage point.
+
+## Verified Current State
+
+- `tanstack-app/app/routes/admin.$.tsx` still returns `segments` from the loader and renders `RootPage`.
+- `packages/tanstack-start/src/views/Root/index.tsx` imports `@payloadcms/ui/views/Root/RenderRoot`.
+- `packages/tanstack-start/src/layouts/Root/index.tsx` already sets up `RootProvider`, TanStack router integration, auth, translations, config, and server functions.
+- `packages/ui` already contains reusable client pieces such as `DefaultListView`, `DefaultEditView`, `CreateFirstUserClient`, and dashboard client modules.
+- `render-document` and `render-list` still return React nodes today, so they are not suitable as serializable TanStack page data primitives.
+- The current default template and nav are server-oriented and rely on `RenderServerComponent`.
+
+## Design
+
+### Rendering model
+
+**Next.js**
+
+- Keeps `renderRootPage`
+- Keeps RSC views and templates
+- No behavior change
+
+**TanStack Start**
+
+- Loader calls `getPageState`
+- `getPageState` returns serializable data only
+- Route component renders `TanStackAdminPage`
+- `TanStackAdminPage` is client-only
+- Client wrappers map `viewType` to reusable client implementations
+- Mutations that currently depend on `render-document` / `render-list` invalidate the route instead of returning React nodes
+
+This is acceptable because Payload admin is authenticated application UI, not an SEO page.
+
+## Core Principles
+
+### 1. Do not serialize React nodes
+
+No TanStack loader or server function used for route rendering should return:
+
+- `React.ReactNode`
+- rendered server components
+- server-only component references
+
+Only plain serializable data should cross the route boundary.
+
+### 2. Do not repurpose the existing `packages/ui` server views as-is
+
+The current server view entry points in `packages/ui` stay in place for Next.js.
+
+TanStack should instead:
+
+- add a page-state layer in `packages/tanstack-start`
+- add client wrappers in `packages/tanstack-start`
+- reuse client components from `packages/ui` where those already exist
+
+### 3. Keep the existing TanStack root layout
+
+The current `RootLayout` in `packages/tanstack-start` already provides the base app shell and providers. The new page flow should fit inside that tree.
+
+`TanStackAdminPage` should use `PageConfigProvider` at the page level, not replace `RootProvider`.
+
+## Main Components
+
+### 1. `getPageState`
+
+**File:** `packages/tanstack-start/src/views/Root/getPageState.ts`
+
+This is the new server-side entry point for route rendering.
+
+Responsibilities:
+
+- call `initReq`
+- perform auth/public-route/custom-route checks
+- reproduce route resolution currently handled before rendering
+- derive serializable page state from `getRouteData`
+- throw TanStack `redirect` / `notFound` as needed
+
+### Serializable state shape
+
+At minimum:
+
+```ts
+type SerializablePageState = {
+ browseByFolderSlugs: string[]
+ clientConfig: ClientConfig
+ documentSubViewType?: DocumentSubViewTypes
+ locale?: Locale
+ navPreferences?: NavPreferences
+ permissions: SanitizedPermissions
+ routeParams: {
+ collection?: string
+ folderCollection?: string
+ folderID?: number | string
+ global?: string
+ id?: number | string
+ token?: string
+ versionID?: number | string
+ }
+ searchParams?: Record
+ segments: string[]
+ templateClassName: string
+ templateType?: 'default' | 'minimal'
+ viewType?: ViewTypes
+ visibleEntities: VisibleEntities
+ customView?: PayloadComponent
+ viewActions?: PayloadComponent[]
+}
+```
+
+### Important notes
+
+- Built-in views should be represented by `viewType`, not by serializing a React function.
+- `viewActions` and `customView` are only safe when they are import-map-addressable payload components.
+- Direct function components should not be serialized through page state.
+- `visibleEntities` or precomputed nav groups must be present so the client template can build navigation without server-only helpers.
+
+### 2. Route loader rewrite
+
+**File:** `tanstack-app/app/routes/admin.$.tsx`
+
+Current route behavior:
+
+- resolves `segments`
+- performs some auth logic inline
+- renders ``
+
+Target route behavior:
+
+- resolves `segments`
+- delegates page-state work to `getPageState`
+- renders ``
+
+The route component should pass TanStack search params as plain route search state into the page shell.
+
+### 3. `TanStackAdminPage`
+
+**File:** `packages/tanstack-start/src/views/Root/TanStackAdminPage.tsx`
+
+Requirements:
+
+- `'use client'`
+- no server-only imports
+- uses `PageConfigProvider`
+- chooses template by `templateType`
+- chooses built-in view wrapper by `viewType`
+- resolves supported import-map custom views on the client
+
+This component is the top of the new TanStack page tree.
+
+## Templates
+
+The existing `packages/ui` templates are server-oriented and should not be reused directly in the TanStack page path.
+
+Instead create TanStack-safe client templates under `packages/tanstack-start`.
+
+### Default template
+
+Should:
+
+- use client-safe wrappers and layout primitives
+- use `DefaultNavClient` rather than `DefaultNav`
+- rely on `visibleEntities` or precomputed nav groups
+- preserve the admin shell structure where possible
+- avoid `RenderServerComponent`
+
+### Minimal template
+
+Should:
+
+- cover login/reset/verify-like minimal routes
+- remain client-safe
+- avoid server-render helpers
+
+## View Strategy
+
+TanStack needs its own client wrappers for built-in views.
+
+### Reuse from existing client components
+
+Reuse existing client components in `packages/ui` where practical:
+
+- list: `DefaultListView`
+- document: `DefaultEditView`
+- create first user: `CreateFirstUserClient`
+- account: `AccountClient`
+- dashboard internals: modular dashboard client pieces
+
+### Views with server-only current entry points
+
+If a view still only exists as a server entry point, add a TanStack client wrapper that:
+
+- consumes serializable state from `getPageState`, or
+- fetches serializable data via existing server functions or new data-only helpers
+
+Do not route page rendering through the server view entry point.
+
+### Custom views
+
+TanStack should only support custom views that can be addressed through the import map on the client.
+
+If a custom view is only represented by a direct React function and cannot be safely serialized or resolved client-side, it should not be passed through route state.
+
+## List Data
+
+The list page needs a data-only path instead of the current RSC rendering path.
+
+### Shared utility
+
+Add a small `packages/ui` exception:
+
+- `packages/ui/src/utilities/buildTableData.ts`
+
+Export it from:
+
+- `packages/ui/src/exports/utilities.ts`
+
+This helper should:
+
+- share data-fetching and column-building behavior with `buildTableState`
+- return serializable data only
+- not render `Table`
+- not render filters
+
+### Consistency requirement
+
+Do not let TanStack and Next.js diverge accidentally.
+
+If shared list-fetch logic is adjusted, either:
+
+- preserve existing behavior in both paths, or
+- intentionally fix the behavior in both places
+
+## Document Data
+
+The document page also needs a serializable, non-RSC path.
+
+TanStack should not use `renderDocument` for route rendering because it returns React nodes.
+
+Instead:
+
+- derive document state with existing server-side utilities where possible
+- return plain data, permissions, preferences, locks, form state, and route metadata
+- mount a client wrapper that reuses `DefaultEditView` and the required client providers
+
+## Dashboard
+
+Dashboard is the highest-risk view because the current server path still wraps modular dashboard pieces with server rendering behavior.
+
+Recommended initial scope:
+
+- make the default dashboard functional in TanStack
+- reuse modular dashboard client pieces
+- avoid dragging server-only dashboard extensions into the TanStack client graph
+
+If some advanced dashboard customizations are still server-only, keep the first pass conservative instead of reintroducing the underlying problem.
+
+## Server Functions and Invalidation
+
+The shared dispatcher in `packages/ui` should remain unchanged.
+
+TanStack-specific behavior belongs in:
+
+- `packages/tanstack-start/src/utilities/handleServerFunctions.ts`
+
+### Special-case invalidation
+
+Intercept:
+
+- `render-document`
+- `render-list`
+
+Instead of returning React nodes, return an invalidation sentinel such as:
+
+```ts
+{
+ __tanstack_invalidate: true
+}
+```
+
+All other server functions should continue dispatching through the existing shared handler.
+
+## Router Invalidation
+
+The TanStack adapter/router path should recognize the invalidation sentinel and call:
+
+- `router.invalidate()`
+
+This keeps the route data model TanStack-native:
+
+- route state comes from the loader
+- updates trigger loader refresh
+- page state rehydrates from fresh serialized data
+
+## Vite Cleanup
+
+Once the TanStack admin route no longer pulls server-only code into the client graph:
+
+- remove `serverOnlyStubPlugin` from `tanstack-app/vite.config.ts`
+
+Keep the compatibility shim plugin that is unrelated to this issue.
+
+## What Does Not Change
+
+- `packages/next`
+- `renderRootPage`
+- the shared server-function dispatcher in `packages/ui`
+- REST and auth semantics
+- the Next.js admin rendering path
+
+## Trade-offs
+
+| Aspect | Next.js | TanStack Start |
+| -------------------------------- | ------------------- | ------------------------------ |
+| View rendering | RSC-first | client wrappers + loader state |
+| Route payload | React nodes allowed | serializable data only |
+| Route refresh | framework RSC flow | loader invalidation |
+| Server-only deps in client graph | none | none after migration |
+| Stub maintenance | not needed | removed |
+
+## Testing
+
+### Integration
+
+Extend `test/admin-adapter/tanstack-start.int.spec.ts` to cover:
+
+- `getPageState` export and shape
+- redirect/notFound behavior
+- invalidation responses for `render-document` and `render-list`
+- normal dispatch behavior for other server functions
+
+### E2E
+
+Add or extend TanStack app coverage for:
+
+- dashboard route loads
+- list route loads
+- document route loads
+- client-side navigation works
+- mutations refresh the route via invalidation
+- browser console has no server-only module errors
+
+### Manual verification
+
+Verify at minimum:
+
+1. TanStack app builds without `serverOnlyStubPlugin`
+2. admin root route renders
+3. list route renders
+4. document route renders
+5. save/delete style flows refresh correctly
+
+## Outcome
+
+This design succeeds when TanStack admin rendering:
+
+- no longer routes through `RootPage` / `RenderRoot`
+- no longer needs `serverOnlyStubPlugin`
+- remains compatible with the existing `RootLayout` provider tree
+- keeps Next.js untouched
diff --git a/docs/plans/2026-04-02-tanstack-start-server-client-boundary-rework.md b/docs/plans/2026-04-02-tanstack-start-server-client-boundary-rework.md
new file mode 100644
index 00000000000..acedf32af89
--- /dev/null
+++ b/docs/plans/2026-04-02-tanstack-start-server-client-boundary-rework.md
@@ -0,0 +1,182 @@
+# TanStack Start Server/Client Boundary Rework Plan
+
+## Goal
+
+Fix TanStack Start `admin-root` e2e by removing server-only code from the client route graph.
+
+This plan explicitly rejects browser-side module shims and mock Node builtins. The correct fix is:
+
+1. keep client bundles client-only
+2. keep server logic behind TanStack server functions or server loaders
+3. split mixed entrypoints so route-tree imports never pull server modules into the browser
+
+## Keep vs Revert
+
+### Keep
+
+- Keep the Sass warning suppression in [tanstack-app/vite.config.ts](/Users/orakhmatulin/work/payload/tanstack-app/vite.config.ts) (`silenceDeprecations: ['import']`)
+- Keep the `@vite-ignore` update in [packages/payload/src/utilities/dynamicImport.ts](/Users/orakhmatulin/work/payload/packages/payload/src/utilities/dynamicImport.ts)
+- Keep the route-level move away from static `@payload-config` imports as part of the rework direction
+
+### Revert
+
+- Revert all browser shim work in [tanstack-app/vite.config.ts](/Users/orakhmatulin/work/payload/tanstack-app/vite.config.ts)
+- Do not add more client aliases for `node:*`, `react-dom/server`, or other server modules
+
+## Verified Problem
+
+The browser failures were not isolated package bugs. They were repeated proof that the client graph still imports server code.
+
+Observed breakages included:
+
+- `file-type`
+- `sharp`
+- `node:stream/web`
+- `react-dom/server`
+- `node:async_hooks`
+- `util.isDeepStrictEqual`
+- `ieee754`
+
+These were downstream symptoms of one root issue: TanStack route modules imported server-oriented entrypoints that are also pulled into the client by [tanstack-app/app/routeTree.gen.ts](/Users/orakhmatulin/work/payload/tanstack-app/app/routeTree.gen.ts).
+
+## Root Cause
+
+### 1. Route-tree modules are client-visible
+
+[tanstack-app/app/routeTree.gen.ts](/Users/orakhmatulin/work/payload/tanstack-app/app/routeTree.gen.ts) imports:
+
+- [tanstack-app/app/routes/\_\_root.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/__root.tsx)
+- [tanstack-app/app/routes/admin.index.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/admin.index.tsx)
+- [tanstack-app/app/routes/admin.$.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/admin.$.tsx)
+
+Anything statically imported by those files is at risk of entering the client graph.
+
+### 2. `__root.tsx` imports a server-oriented layout entrypoint
+
+[tanstack-app/app/routes/\_\_root.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/__root.tsx) currently depends on `RootLayout` behavior from `@payloadcms/tanstack-start`.
+
+[packages/tanstack-start/src/layouts/Root/index.tsx](/Users/orakhmatulin/work/payload/packages/tanstack-start/src/layouts/Root/index.tsx) imports and executes server-oriented concerns:
+
+- `initReq`
+- nav preference lookup
+- request/theme/cookie derivation
+- client config generation
+- locale filtering
+- server action wiring
+
+That file is not safe as a static route-tree import.
+
+### 3. Package root exports are too mixed
+
+`@payloadcms/tanstack-start` currently exposes both client-safe and server-oriented entrypoints from the same package root.
+
+Using the package root from route files makes it too easy to drag server code into the client graph.
+
+## Rework Direction
+
+## Phase 1: Remove the Wrong Fix
+
+1. Remove the browser shim plugin from [tanstack-app/vite.config.ts](/Users/orakhmatulin/work/payload/tanstack-app/vite.config.ts)
+2. Keep only the Sass suppression in that file
+3. Re-run focused TanStack `admin-root` e2e to confirm the route graph still fails without shims
+
+## Phase 2: Split the Root Route into Client Shell + Server Data
+
+### Target state
+
+[tanstack-app/app/routes/\_\_root.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/__root.tsx) should import only client-safe UI and TanStack router components at module scope.
+
+It should not statically import:
+
+- `RootLayout`
+- `initReq`
+- `@payload-config`
+- server-only request helpers
+- mixed package-root exports that transitively pull server modules
+
+### Required refactor
+
+Extract the server work currently embedded in `RootLayout` into a data function that returns only serializable props.
+
+Suggested split:
+
+1. Add a server-only helper in `packages/tanstack-start`
+
+ - example shape: `getRootLayoutData({ config, importMap })`
+ - responsibility: run `initReq`, nav prefs lookup, client config generation, locale filtering, translation/theme derivation
+ - output: plain data needed by the client root shell
+
+2. Add a client-safe root-shell export in `packages/tanstack-start`
+
+ - example shape: `TanStackRootShell`
+ - responsibility: compose `TanStackRouterProvider`, `TanStackRootProvider`, `ProgressBar`, and `Outlet`
+ - input: serialized root layout data, plus the TanStack server function clients
+
+3. Update `__root.tsx`
+ - route loader or `createServerFn` fetches root layout data
+ - component renders the client shell with loader data
+ - server functions stay behind `createServerFn`
+
+## Phase 3: Add Explicit Client-Safe Exports
+
+Avoid importing the mixed `@payloadcms/tanstack-start` package root from route files.
+
+Add explicit client-safe subpath exports for pieces that are allowed in the client route graph, for example:
+
+- router provider
+- root provider shell
+- client page shell
+
+Add explicit server-only subpaths for:
+
+- root layout data builder
+- page-state loader helpers
+- server function dispatcher
+
+## Phase 4: Keep Admin Routes on the Same Pattern
+
+[tanstack-app/app/routes/admin.index.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/admin.index.tsx) and [tanstack-app/app/routes/admin.$.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/admin.$.tsx) should keep page-state fetching behind a TanStack server function.
+
+Follow through on that pattern:
+
+1. keep page-state fetching behind a TanStack server function
+2. keep route modules free of server-only static imports
+3. keep [packages/tanstack-start/src/views/TanStackAdminPage.tsx](/Users/orakhmatulin/work/payload/packages/tanstack-start/src/views/TanStackAdminPage.tsx) client-only
+
+## Phase 5: Re-evaluate Payload-Side Workarounds
+
+Some Payload changes made during triage may still be valid independently of TanStack.
+
+Rule:
+
+- do not keep a Payload change just because it delayed the next client import crash
+- keep only changes that are correct on their own merits after the route boundary is fixed
+
+## Phase 6: Resolve the API Route Warning Separately
+
+[tanstack-app/app/routes/api.$.ts](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/api.$.ts) still produces the route-tree warning that it does not export `Route`.
+
+Treat that as a separate cleanup task:
+
+1. verify the correct TanStack Start API route file convention for this app version
+2. move or rename the file to the framework-approved location/pattern
+3. ensure the fix does not put the API handler back into the client route graph
+
+## Validation
+
+After the rework:
+
+1. run focused lint on the touched TanStack and Payload files
+2. run `PAYLOAD_FRAMEWORK=tanstack-start pnpm run test:e2e admin-root --workers=1`
+3. confirm the browser no longer crashes on server-only imports
+4. only then debug real UI/test assertion failures
+
+## Suggested Implementation Order
+
+1. remove the browser shims
+2. extract root layout server data from `RootLayout`
+3. add client-safe TanStack root-shell exports/subpaths
+4. update `__root.tsx` to use only client-safe static imports
+5. rerun focused e2e
+6. prune any Payload-side workaround that is no longer justified
+7. fix the API route warning separately
diff --git a/docs/plans/2026-04-02-ui-framework-agnostic-views.md b/docs/plans/2026-04-02-ui-framework-agnostic-views.md
new file mode 100644
index 00000000000..8d16062ff2a
--- /dev/null
+++ b/docs/plans/2026-04-02-ui-framework-agnostic-views.md
@@ -0,0 +1,705 @@
+# UI Framework-Agnostic Views
+
+## Goal
+
+Move shared admin view logic toward a framework-agnostic layer in `@payloadcms/ui`, while moving Next-specific request, routing, and React Server Component behavior into `@payloadcms/next`.
+
+This document supersedes the recent TanStack-specific client-shell direction from:
+
+- `docs/plans/2026-04-02-tanstack-start-client-rendering.md`
+- `docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md`
+
+Those documents assumed the right answer was to add a TanStack-only page shell beside the existing Next path. The better direction is to make the shared admin view layer portable first, then let each framework package provide its own runtime wrapper.
+
+## Why The Previous Direction Is Wrong
+
+The recent TanStack work correctly identified that the current `ui` admin rendering path is not safely consumable by an isomorphic route graph, but it pushed the fix too far into `packages/tanstack-start`.
+
+That approach has two problems:
+
+1. It duplicates admin view and template behavior in a second package instead of fixing the boundary in the shared layer.
+2. It preserves the assumption that Next keeps the "real" admin path and other frameworks must adapt around it with client-only substitutes.
+
+If `@payloadcms/ui` is truly the shared admin UI package, it should own the framework-agnostic view contracts and composition rules. `@payloadcms/next` should own the Next-specific runtime and RSC integration.
+
+## Verified Current Boundary Problem
+
+### Shared `ui` still owns framework-shaped rendering
+
+`packages/ui/src/views/Root/RenderRoot.tsx` is described as framework-agnostic, but it still owns:
+
+- route-to-view orchestration
+- auth redirects
+- template selection
+- direct rendering of payload components through `RenderServerComponent`
+
+`packages/ui/src/views/Dashboard/index.tsx` is also not just a shared dashboard view. It currently mixes:
+
+- request-bound data fetches like `getGlobalData(req)`
+- server props assembly
+- `RenderServerComponent` invocation for dashboard component resolution
+
+### `RenderServerComponent` is the hidden framework seam
+
+`packages/ui/src/elements/RenderServerComponent/index.tsx` currently decides:
+
+- how `PayloadComponent` values are resolved from the import map
+- whether a resolved component is treated as an RSC-like server component
+- when `serverProps` are merged into the render call
+
+That is the main coupling point between:
+
+- shared view and template description in `ui`
+- framework-specific server rendering behavior
+
+### `ui` still contains Next-specific assumptions
+
+The clearest example is `packages/ui/src/elements/Link/index.tsx`, which imports `next/link.js` directly. That means `ui` still embeds a runtime-specific navigation primitive instead of consuming an adapter-provided one.
+
+## Current Layering
+
+```mermaid
+flowchart LR
+ nextEntry[NextRootPage] --> nextInit[NextInitReq]
+ nextEntry --> uiRoot[ui renderRootPage]
+ uiRoot --> uiRoute[getRouteData]
+ uiRoot --> uiTemplate[DefaultTemplate or MinimalTemplate]
+ uiRoot --> uiRenderer[RenderServerComponent]
+ uiTemplate --> uiRenderer
+ uiDashboard[DashboardView] --> uiRenderer
+```
+
+Today, `packages/next` is mostly a wrapper around a rendering model that still lives in `ui`.
+
+That is backward for a portable architecture.
+
+## Target Architecture
+
+```mermaid
+flowchart LR
+ uiRoute[UiRouteDescriptorBuilder] --> uiView[UiViewDescriptor]
+ uiView --> uiTemplate[UiTemplateDescriptor]
+ uiView --> uiSlots[UiSlotDeclarations]
+ uiDashboard[UiDashboardDescriptor] --> uiView
+
+ nextRuntime[NextRuntime] --> nextAdapter[NextViewAdapter]
+ nextAdapter --> nextRenderer[NextComponentRenderer]
+ nextAdapter --> uiRoute
+ nextRenderer --> uiTemplate
+ nextRenderer --> uiSlots
+
+ tanstackRuntime[TanStackRuntime] --> tanstackAdapter[TanStackViewAdapter]
+ tanstackAdapter --> tanstackRenderer[TanStackComponentRenderer]
+ tanstackAdapter --> uiRoute
+ tanstackRenderer --> uiTemplate
+ tanstackRenderer --> uiSlots
+```
+
+In the target state:
+
+- `@payloadcms/ui` defines route resolution, shared view descriptors, template contracts, slot declarations, and dashboard behavior.
+- `@payloadcms/next` owns Next request initialization, `redirect` / `notFound`, server actions, `next/navigation`, document shell wiring, and RSC-aware component rendering.
+- `@payloadcms/tanstack-start` consumes the same shared descriptors through its own runtime adapter instead of recreating view/template logic.
+
+## Boundary Rules
+
+### `@payloadcms/ui` should own
+
+- route and view descriptors
+- template and slot contracts
+- shared dashboard view composition
+- synchronous shared root/bootstrap composition
+- framework-neutral router interfaces and provider contracts
+- framework-neutral component resolution interfaces
+
+### `@payloadcms/next` should own
+
+- `initReq` built on `next/headers`
+- `redirect` / `notFound`
+- Next-specific invocation transport such as server actions, route handlers, and cookie writes
+- `next/navigation` router integration
+- `next/link` integration
+- async request-bound execution needed to prepare root/bootstrap inputs for RSC
+- RSC-aware payload component execution
+- app-router document shell concerns
+
+### `@payloadcms/tanstack-start` should own
+
+- TanStack route loaders, server functions, or REST wiring
+- TanStack router integration
+- TanStack-specific request/runtime adapters
+- use of shared `ui` descriptors instead of placeholder templates or duplicate views
+
+### Reusable server-side logic vs framework transport
+
+`@payloadcms/ui` can still own reusable admin-side logic that happens to run on the server. The important boundary is not "server code vs client code." The important boundary is "shared Payload/business logic vs framework-specific execution transport."
+
+That means shared files such as:
+
+- `packages/ui/src/utilities/copyDataFromLocale.ts`
+- `packages/ui/src/utilities/buildFormState.ts`
+- `packages/ui/src/utilities/buildTableState.ts`
+- `packages/ui/src/utilities/slugify.ts`
+
+can remain in `ui` when they provide reusable logic like:
+
+- Payload Local API reads/writes
+- access checks and permission-aware data shaping
+- shared result formatting or merge logic
+- framework-neutral request-aware helpers that accept `req`
+
+What should not be locked into `ui` is a Next-shaped execution model for invoking that logic.
+
+The adapter packages should own how shared logic is exposed to their runtime:
+
+- `@payloadcms/next` can call it through a Next server action, route handler, or RSC wrapper
+- `@payloadcms/tanstack-start` can call it through a loader, server function, or plain REST request
+- another framework might skip server actions entirely and call the same shared logic through a REST API endpoint
+
+So the long-term contract should be:
+
+1. keep reusable logic in `ui` where it is genuinely framework-agnostic
+2. keep framework transport and runtime wiring in the adapter package
+3. avoid making "server actions" themselves the shared primitive, because some frameworks will not support that concept at all
+
+## Root Bootstrap Boundary Correction
+
+The immediate target is not "fix nav." The immediate target is to fix the root/bootstrap components without which another framework cannot even load the first admin page.
+
+The shared layer currently still mixes two different concerns inside those bootstrap paths:
+
+- synchronous shared composition
+- request/runtime-specific async execution
+
+That split should be inverted.
+
+### What counts as a root/bootstrap component
+
+For this phase, "root/bootstrap" means the components and helpers that are required to decide, assemble, and render the very first admin page shell at all. That includes things like:
+
+- root route/view orchestration
+- template selection and shell assembly
+- root-level slot preparation
+- framework-specific component execution needed by that first page
+
+`packages/ui/src/elements/Nav/index.tsx` is only an example of the problem, because it sits on the first-page path and currently mixes shared composition with request-bound async work. It is not the whole target by itself.
+
+### Target shape for root/bootstrap
+
+The shared `ui` entries on the first-page path should move toward synchronous composition layers that accept already-resolved inputs.
+
+In practice, that means `ui` should own things like:
+
+- root descriptors
+- template/shell composition
+- already-grouped or already-resolved root UI inputs
+- framework-neutral slot/component specifications
+
+And the framework package should own the async wrapper above them.
+
+### What `next` should do instead
+
+If Next needs async behavior for RSC, that async boundary should live in `@payloadcms/next`, not in the shared `ui` bootstrap components.
+
+That Next wrapper should be responsible for:
+
+- loading request-bound state needed to render the first page
+- resolving any RSC/server-rendered root slots
+- passing the fully resolved props into the synchronous shared `ui` bootstrap entries
+
+This is the same broader rule as the rest of the document: `ui` describes and composes, while `next` performs request-bound async execution.
+
+### What TanStack should do instead
+
+TanStack should consume the same synchronous shared bootstrap contracts using its own runtime state and router bindings.
+
+It should not require `ui` to accumulate adapter-motivated helper leaves that are only useful because the shared root/bootstrap boundary is still in the wrong place.
+
+If a helper like `packages/ui/src/elements/Nav/AdminNavLinks.tsx` exists during migration, it should be treated as transitional unless it becomes part of the canonical shared path that the main `ui` bootstrap components themselves use.
+
+## Renderer Boundary Proposal
+
+The current `RenderServerComponent` API is too concrete. It renders immediately and embeds framework behavior in the shared layer.
+
+The first architectural step should be to replace "render now" with "describe what must be rendered."
+
+### Proposed split
+
+1. Keep import-map and payload-component concepts in `ui`, but stop coupling them directly to a framework rendering decision.
+2. Introduce a renderer boundary that can be implemented by `next` first and by TanStack later.
+
+### Proposed shared types
+
+```ts
+type ViewComponentSpec = {
+ component?: PayloadComponent | React.ComponentType
+ fallback?: React.ComponentType
+ clientProps?: object
+ serverProps?: object
+}
+
+type ViewSlotSpec = {
+ key: string
+ spec: ViewComponentSpec
+}
+
+type FrameworkViewRenderer = {
+ renderComponent: (spec: ViewComponentSpec) => React.ReactNode
+ renderSlots: (specs: ViewSlotSpec[]) => Record
+}
+```
+
+This is intentionally small.
+
+The important shift is:
+
+- `ui` produces `ViewComponentSpec` and `ViewSlotSpec`
+- the framework adapter decides how to execute them
+
+### What changes in `ui`
+
+The following files should stop invoking rendering directly and instead produce shared declarations or consume an injected renderer:
+
+- `packages/ui/src/elements/RenderServerComponent/index.tsx`
+- `packages/ui/src/views/Root/RenderRoot.tsx`
+- `packages/ui/src/templates/Default/index.tsx`
+- `packages/ui/src/views/Dashboard/index.tsx`
+
+### What changes in `next`
+
+`packages/next` should own the first renderer implementation for the new boundary.
+
+That implementation should preserve current behavior by continuing to:
+
+- resolve payload components through the import map
+- detect RSC/server component behavior where needed
+- merge server props only in the Next runtime
+
+## Root Refactor: Shared Descriptor + Framework Wrapper
+
+The current `renderRootPage` path should be split in two layers.
+
+### Shared `ui` layer
+
+The `ui` layer should build a root-level descriptor from:
+
+- `getRouteData`
+- auth/public-route decisions
+- visible entities
+- client config
+- template selection
+- slot/component specifications
+
+The output should be a structured description of what the page is, not an already-rendered tree.
+
+### Framework wrapper layer
+
+The framework package should provide:
+
+- request initialization
+- framework redirects and not-found behavior
+- renderer implementation
+- final page/tree assembly
+
+For Next, this means `packages/next/src/views/Root/index.tsx` becomes a true adapter entrypoint instead of a thin passthrough to a monolithic `ui` renderer.
+
+## Root-First Migration Slice
+
+The first implementation slice should prove the architecture with only the root/bootstrap components required to load the first admin page.
+
+### Scope
+
+Included:
+
+- root descriptor boundary
+- renderer abstraction
+- root/bootstrap shell migration
+- Next adapter ownership of async/RSC execution for this slice
+
+Deferred:
+
+- dashboard-specific cleanup beyond what the first page needs
+- nav-specific cleanup beyond what the first page needs
+- auth and minimal-template views like login / forgot / reset / logout / verify / unauthorized / create-first-user
+- list, browse-by-folder, and collection-folder rendering
+- document, edit, account, API, version, and versions rendering
+- splitting TanStack view ownership into per-view modules that mirror the existing Next view tree
+- full template cleanup
+- broader custom-view support
+
+### Why Root First
+
+Until the root/bootstrap boundary is corrected, another framework still cannot reliably load the first page without inheriting Next-shaped assumptions from `ui`.
+
+That makes the first-page path the highest-leverage proving slice because it exercises:
+
+- route selection from `getRouteData`
+- template composition
+- root shell assembly
+- root-level slot/component execution
+- request/runtime handoff between `ui` and the framework package
+
+### Root target shape
+
+The first target is not "finish dashboard" or "finish nav." The first target is to make the root/bootstrap path loadable through a shared synchronous composition contract with adapter-owned async execution above it.
+
+Instead, it should move toward:
+
+1. shared root/bootstrap descriptor or composition in `ui`
+2. renderer-driven execution of root component specs in the framework package
+3. Next preserving current behavior through its adapter implementation
+4. the same descriptor/adapter pattern being applied view-by-view after the bootstrap path is stable
+5. TanStack moving from a monolithic admin page file to per-view adapter entrypoints that are easy to compare against `packages/next/src/views`
+
+## Next-Specific Ownership To Expand
+
+The following `next` files already sit near the correct boundary and should continue moving in that direction:
+
+- `packages/next/src/layouts/Root/index.tsx`
+- `packages/next/src/views/Root/index.tsx`
+- `packages/next/src/adapter/RouterProvider.tsx`
+- `packages/next/src/utilities/initReq.ts`
+
+In addition, root/bootstrap primitives currently embedded in `ui` should move behind adapter-owned async wrappers where necessary, starting with:
+
+- `packages/ui/src/elements/Link/index.tsx`
+- the async/request-bound pieces currently mixed into root-path components like `packages/ui/src/elements/Nav/index.tsx`
+- other `ui` root/template/bootstrap entries that are currently `async` only because they perform Next-shaped request work
+
+The long-term goal is that `ui` consumes only framework-neutral router context, while `next` and TanStack each provide their own `Link`, pathname, search-params, and router operations through adapter wiring.
+
+## Immediate Follow-Through: Collapse `TanStackAdminPage` Into Per-View Adapters
+
+Once the root/bootstrap boundary exists, the next TanStack step should not be to keep extending `packages/tanstack-start/src/views/TanStackAdminPage.tsx` as a parallel admin implementation.
+
+That file is now the main duplication hotspot. It already re-implements:
+
+- default template composition through `TanStackDefaultTemplate`
+- dashboard composition through `DashboardView`
+- auth/minimal-template views like `LoginView`, `ForgotPasswordView`, `ResetPasswordView`, `LogoutView`, `VerifyView`, `UnauthorizedView`, and `CreateFirstUserView`
+- account/document-edit composition through `AccountView`
+- list state + rendering through `useListState` and `ListView`
+- document state + rendering through `useDocumentState` and `DocumentView`
+- top-level view selection through a TanStack-only `renderView` switch
+
+At the same time, TanStack server state is already leaning on shared `ui` internals in:
+
+- `packages/tanstack-start/src/views/Root/getPageState.ts`
+- `packages/tanstack-start/src/views/Root/serverFunctions.ts`
+
+That means the next architectural payoff is not another TanStack-only wrapper. It is to first finish turning the shared root/bootstrap `ui` paths into adapter-consumable contracts, then delete the matching custom branches from `TanStackAdminPage.tsx` view by view.
+
+### Target shape
+
+`TanStackAdminPage.tsx` should not remain the long-term home for every TanStack admin view. The target should look much closer to `packages/next/src/views`, with TanStack view entrypoints split by view type instead of centralized behind one switch.
+
+It should either disappear entirely or shrink to a tiny route-level handoff that owns only:
+
+1. TanStack runtime providers and router bindings
+2. handoff from serialized page/server-function state into shared `ui` view entrypoints
+3. adapter-specific renderer execution for payload component slots
+4. temporary unsupported fallbacks only where the shared contract does not exist yet
+
+It should stop owning bespoke template markup or hand-maintained root/dashboard/auth/list/document/account wrappers once the shared contracts are available.
+
+### Target TanStack view structure
+
+TanStack should move toward a per-view folder layout similar to Next so adapter boundaries are explicit and comparable:
+
+```text
+packages/tanstack-start/src/views/
+ Root/
+ index.tsx
+ getPageState.ts
+ serverFunctions.ts
+ types.ts
+ Dashboard/
+ index.tsx
+ Login/
+ index.tsx
+ ForgotPassword/
+ index.tsx
+ ResetPassword/
+ index.tsx
+ Logout/
+ index.tsx
+ Verify/
+ index.tsx
+ Unauthorized/
+ index.tsx
+ CreateFirstUser/
+ index.tsx
+ List/
+ index.tsx
+ Document/
+ index.tsx
+ Account/
+ index.tsx
+ Versions/
+ index.tsx
+ Version/
+ index.tsx
+```
+
+This does not mean TanStack must copy every Next file 1:1. It means the folder structure should communicate the same architecture: one adapter entrypoint per view, shared `ui` contracts underneath, and no single "big ass file" that owns the whole admin surface.
+
+### Required shared extractions
+
+To make that collapse possible, the shared layer needs adapter-facing entrypoints that TanStack can consume without copying behavior:
+
+- root/bootstrap contracts that let TanStack load the first page without depending on async `ui` entrypoints
+- template contracts that let TanStack use shared default/minimal composition instead of `TanStackDefaultTemplate`
+- dashboard descriptor/state helpers that replace the current client-only `DashboardView`
+- auth/minimal-view entrypoints for login, forgot-password, reset-password, logout, verify, unauthorized, and create-first-user flows
+- list descriptor/state builders that replace the TanStack-specific `table-state` -> `DefaultListView` bridge
+- browse/folder view contracts where TanStack currently needs collection-specific route handling
+- document, edit, and account descriptor/state builders that replace the TanStack-specific `tanstack-document-state` -> `DefaultEditView` bridge
+- version and versions contracts so edit-related subviews also follow the same adapter boundary
+- a supported custom-view contract only after the core built-in views have stopped depending on framework-specific rendering in `ui`
+
+The important constraint is that these should be shared `ui` contracts, not TanStack-specific forks of `renderListView` or `renderDocument`.
+
+For auth/minimal views specifically, the end state should be reuse of the shared `ui` view implementations themselves wherever they already exist, not permanent duplication in each adapter package. Files like:
+
+- `packages/ui/src/views/Login/index.tsx`
+- `packages/ui/src/views/ForgotPassword/index.tsx`
+- `packages/ui/src/views/ResetPassword/index.tsx`
+- `packages/ui/src/views/Logout/index.tsx`
+- `packages/ui/src/views/Verify/index.tsx`
+- `packages/ui/src/views/Unauthorized/index.tsx`
+- `packages/ui/src/views/CreateFirstUser/index.tsx`
+
+should become the canonical shared view entries consumed by both Next and TanStack once any remaining framework-specific transport or wrapper concerns are pulled up into the adapter layer.
+
+The same structural rule should apply to the rest of the built-in admin views too. The auth/minimal views are only the first easy slice. After that, the long-term target for `DashboardView`, `ListView`, `DocumentView`, `EditView`, `AccountView`, `VersionView`, and related folder/browse views should still be:
+
+1. one canonical shared `ui` view implementation or renderer contract
+2. thin framework adapters in `next` and TanStack
+3. deletion of adapter-specific copies once the shared `ui` view can be consumed directly
+
+Concrete examples of the shared `ui` side that should move toward that reusable role include:
+
+- `packages/ui/src/views/Dashboard/index.tsx`
+- `packages/ui/src/views/List/RenderListView.tsx`
+- `packages/ui/src/views/Document/RenderDocument.tsx`
+- `packages/ui/src/views/Edit/index.tsx`
+
+`packages/next/src/views/Edit/index.tsx` already shows the intended shape in miniature: a very thin framework entrypoint over shared `ui` edit behavior. The same idea should eventually apply to the other built-in views as their remaining framework seams are removed.
+
+For root/bootstrap specifically, the target is not "extract another helper from `ui` because TanStack needs it." The target is to make the main shared first-page path adapter-consumable, with Next owning any async wrapper above it.
+
+### Collapse order
+
+1. identify the root/bootstrap `ui` components required to load the first admin page
+2. move async/request-bound work for those components into `packages/next` wrappers
+3. make the shared `ui` bootstrap path synchronous and adapter-consumable
+4. route TanStack first-page loading through those same shared root/bootstrap contracts
+5. split the simplest TanStack views into per-view adapters first, starting with auth/minimal-template views that already map cleanly to Next folders
+6. apply the same extraction pattern to dashboard, list, browse/folder, document/edit, account, version, and versions views
+7. reduce any remaining TanStack root dispatcher to a thin handoff over per-view entrypoints instead of a second admin implementation
+
+### Likely touchpoints for this follow-through
+
+- `packages/tanstack-start/src/views/TanStackAdminPage.tsx`
+- new TanStack view folders under `packages/tanstack-start/src/views/*`
+- `packages/ui/src/views/Root/RenderRoot.tsx`
+- `packages/ui/src/elements/RenderServerComponent/index.tsx`
+- `packages/ui/src/elements/Nav/index.tsx`
+- `packages/ui/src/elements/Nav/index.client.tsx`
+- `packages/tanstack-start/src/views/Root/getPageState.ts`
+- `packages/tanstack-start/src/views/Root/serverFunctions.ts`
+- `packages/tanstack-start/src/views/Root/types.ts`
+- `packages/next/src/views/Root/index.tsx`
+- `packages/next/src/layouts/Root/index.tsx`
+- `packages/ui/src/templates/Default/index.tsx`
+- `packages/ui/src/templates/Minimal/index.tsx`
+- `packages/next/src/views/Login/index.tsx`
+- `packages/next/src/views/List/index.tsx`
+- `packages/next/src/views/Document/index.tsx`
+- `packages/next/src/views/Account/index.tsx`
+
+## Deferred Follow-Up Phases
+
+### Phase 2: Auth And Minimal Views
+
+Apply the same pattern to the simpler built-in views first so TanStack stops routing them through one monolith:
+
+- shared auth/minimal view contracts in `ui`
+- framework redirects, router integration, and runtime-specific execution in the adapter package
+- TanStack view folders for login, forgot-password, reset-password, logout, verify, unauthorized, and create-first-user only as a migration step toward reusing the shared `ui` entries
+
+The target for this phase is not "one TanStack file per auth view forever." The target is:
+
+1. make the existing shared `ui` auth/minimal view entries reusable across frameworks
+2. keep any Next-only or TanStack-only transport concerns in the adapter package
+3. delete adapter-specific copies once the shared `ui` view can be consumed directly
+
+`packages/ui/src/views/Logout/index.tsx` is a good example of the desired destination: one canonical shared view implementation that both `@payloadcms/next` and `@payloadcms/tanstack-start` should reuse. The same rule should apply to the other similar `ui` auth/minimal view files once their remaining framework seams are removed.
+
+Likely touchpoints:
+
+- `packages/ui/src/views/Login`
+- `packages/ui/src/views/ForgotPassword`
+- `packages/ui/src/views/ResetPassword`
+- `packages/ui/src/views/Logout`
+- `packages/ui/src/views/Verify`
+- `packages/ui/src/views/Unauthorized`
+- `packages/ui/src/views/CreateFirstUser`
+- `packages/next/src/views/Login/index.tsx`
+- `packages/tanstack-start/src/views/Login/index.tsx`
+
+### Phase 3: Dashboard, List, And Folder Views
+
+Apply the same pattern to the main collection navigation surface by separating:
+
+- shared dashboard/list/folder descriptors and slot declarations in `ui`
+- framework rendering and request/runtime behavior in the adapter package
+- per-view TanStack adapters instead of inline branches inside a single page component
+
+The target for this phase is not "one TanStack `DashboardView` / `ListView` / folder view file forever." It is the same pattern as Phase 2:
+
+1. make the shared `ui` dashboard/list/folder entries reusable across frameworks
+2. keep framework-specific request transport and routing in the adapter package
+3. delete adapter-specific copies once TanStack and Next can consume the shared `ui` view directly
+
+### Proven pattern: dashboard as reference implementation
+
+The dashboard view is now the **reference implementation** for how all other views should converge. The pattern it demonstrates must be repeated for every built-in admin view (list, document/edit, account, version, etc.).
+
+#### What was done for dashboard
+
+1. **Shared client component in `ui`**: `ModularDashboardClient`, `RenderWidget`, `useDashboardLayout`, `DashboardStepNav`, `WidgetEditControl`, `WidgetConfigDrawer`, and all related dnd-kit interaction code live in `packages/ui/src/views/Dashboard/Default/ModularDashboard/`. These are purely client-side React components with no RSC or framework dependency.
+
+2. **Public exports from `@payloadcms/ui`**: `ModularDashboardClient`, `RenderWidget`, `WidgetInstanceClient`, `WidgetItem`, and `DropTargetWidget` are exported from `@payloadcms/ui`'s root client entry. No deep relative cross-package imports are used.
+
+3. **Next adapter** (`packages/next/src/views/Dashboard/Default/ModularDashboard/index.tsx`): the RSC server wrapper imports `ModularDashboardClient` and types directly from `@payloadcms/ui`. It fetches layout from preferences, server-renders widgets via `RenderServerComponent`, and passes the result to the shared `ModularDashboardClient`. There is no intermediate re-export file — adapter code imports from the `@payloadcms/ui` package boundary, not through local wrapper files.
+
+4. **TanStack adapter** (`packages/tanstack-start/src/views/Dashboard/index.tsx`): imports `ModularDashboardClient` and `RenderWidget` from `@payloadcms/ui`. Gets layout items from `pageState.pageData.dashboard.layoutItems` (fetched server-side in `getPageState`). Builds `WidgetInstanceClient[]` using `RenderWidget` for each widget (client-side rendering via the `render-widget` server function). Passes `clientLayout` and `widgets` to the same `ModularDashboardClient`.
+
+5. **Server function dispatch**: TanStack's `handleServerFunctions` falls through to the shared `dispatchServerFunction` from `@payloadcms/ui/utilities/handleServerFunctions`, which already registers `render-widget` and `get-default-layout`. No TanStack-specific handler was needed.
+
+6. **Same visual result**: both frameworks render the full modular dashboard with drag-and-drop, widget editing, layout persistence, and breadcrumb navigation because both consume the same shared `ModularDashboardClient`.
+
+#### The key architectural difference
+
+- **Next** server-renders widgets initially via `RenderServerComponent` (RSC), then hands off to `ModularDashboardClient` for client interaction.
+- **TanStack** renders all widgets client-side via `RenderWidget` (which calls the `render-widget` server function on mount), then hands off to the same `ModularDashboardClient`.
+
+Both approaches produce the same interactive result. The only difference is initial render strategy, which is inherently framework-specific.
+
+#### This exact pattern must be applied to every remaining view
+
+The dashboard is not special. The same structural rule applies to **all** built-in admin views:
+
+| View | Shared `ui` component | Next adapter | TanStack adapter |
+| ------------- | ----------------------------------------------------------- | ----------------------------- | --------------------------------------------- |
+| Dashboard | `ModularDashboardClient` | RSC wrapper (direct import) | `RenderWidget` + `pageState` |
+| List | `DefaultListView` / `RenderListView` | RSC data fetch + pass-through | Server function fetch + same shared component |
+| Document/Edit | `DefaultEditView` / `RenderDocument` | RSC data + form state | Server function data + same shared component |
+| Account | `AccountClient` / `RenderAccount` | RSC wrapper | Server function + same shared component |
+| Version | Shared version diff/comparison UI | RSC wrapper | Server function + same shared component |
+| Versions | Shared versions list UI | RSC wrapper | Server function + same shared component |
+| Browse/Folder | `DefaultBrowseByFolderView` / `DefaultCollectionFolderView` | RSC + redirect handling | Server function + same shared component |
+
+For each view, the work is:
+
+1. Ensure the core client UI component lives in `@payloadcms/ui` and is publicly exported
+2. `@payloadcms/next` becomes a thin adapter: RSC data fetching + pass-through to the shared component
+3. `@payloadcms/tanstack-start` becomes a thin adapter: server function data fetching + same shared component
+4. Both produce the same visual and interactive result
+5. No deep relative cross-package imports; always use `@payloadcms/ui` package boundary
+6. **No re-export wrapper files in adapter packages.** When `@payloadcms/ui` publicly exports a component or type, adapter code should import it directly from `@payloadcms/ui` — not through a local file that just re-exports the same thing. Re-export wrappers add indirection without value and make it harder to verify that the shared implementation is actually being used
+
+The main exception remains code genuinely tied to Next's RSC execution model (`RenderServerComponent`, request-bound server props). Those pieces stay adapter-owned while the visual/interaction layer lives in `ui`.
+
+Likely touchpoints:
+
+- `packages/ui/src/views/Dashboard/index.tsx`
+- `packages/ui/src/views/List/RenderListView.tsx`
+- `packages/ui/src/views/List/renderListViewSlots.tsx`
+- `packages/next/src/views/Dashboard/index.tsx`
+- `packages/next/src/views/List/index.tsx`
+- `packages/next/src/views/BrowseByFolder/buildView.tsx`
+- `packages/next/src/views/CollectionFolders/buildView.tsx`
+
+Folder views are also a useful positive precedent here: `packages/next/src/views/BrowseByFolder/buildView.tsx` and `packages/next/src/views/CollectionFolders/buildView.tsx` already reuse shared `DefaultBrowseByFolderView` / `DefaultCollectionFolderView` UI from `@payloadcms/ui` while keeping redirect and request handling in Next.
+
+**List and folder views must follow the same proven dashboard pattern**: shared client component in `ui`, publicly exported from `@payloadcms/ui`, Next as a thin RSC adapter, TanStack as a thin server-function adapter, same visual result from both.
+
+### Phase 4: Document, Edit, Account, And Version Views
+
+Apply the same pattern to edit-heavy views by separating:
+
+- shared document/account/version descriptors and composition rules in `ui`
+- framework execution of payload components, server data, and edit-specific runtime behavior in the adapter package
+- TanStack adapters for document, account, version, and versions views that mirror Next's high-level structure
+
+Again, the end state is not permanent TanStack-only `DocumentView` / `AccountView` / `EditView` copies. **Follow the exact same pattern that was proven with the dashboard:**
+
+1. The shared client UI component lives in `@payloadcms/ui` and is publicly exported (e.g. `RenderDocument`, `DefaultEditView`, `AccountClient`)
+2. `@payloadcms/next` is a thin RSC adapter that fetches data server-side and passes it to the shared component
+3. `@payloadcms/tanstack-start` is a thin server-function adapter that fetches data via `pageState` or server functions and passes it to the same shared component
+4. Both produce the same visual and interactive result
+5. No deep relative cross-package imports; always use `@payloadcms/ui` package boundary
+6. Adapter-specific copies disappear as the shared `ui` contracts become consumable directly
+
+Other likely places that should be audited with the same rule, because they currently appear to contain reusable visual/admin interaction logic in `packages/next` rather than framework-only transport, include:
+
+- `packages/next/src/views/Version/Default/index.tsx`
+- `packages/next/src/views/Version/Restore/index.tsx`
+- `packages/next/src/views/API/index.client.tsx`
+
+These should be treated identically to how the dashboard was handled: if the behavior is primarily UI, form, comparison, modal, or client interaction logic rather than RSC-specific execution, the default implementation should move to `@payloadcms/ui` so both Next and TanStack can reuse it and converge on the same visual result.
+
+Likely touchpoints:
+
+- `packages/ui/src/views/Document/RenderDocument.tsx`
+- `packages/ui/src/views/Document/renderDocumentSlots.tsx`
+- `packages/next/src/views/Document/index.tsx`
+- `packages/next/src/views/Account/index.tsx`
+- `packages/next/src/views/Version`
+- `packages/next/src/views/Versions`
+
+### Phase 5: Templates And Custom Views
+
+After the built-in view boundaries are stable:
+
+- revisit `DefaultTemplate` and `MinimalTemplate`
+- move more slot rendering to descriptor-driven contracts
+- define the supported boundary for custom admin views across frameworks
+
+## Non-Goals For Phase 1
+
+- Do not finish the full TanStack admin implementation in this slice.
+- Do not redesign dashboard, list, or document rendering yet unless the root/bootstrap path requires it.
+- Do not preserve the TanStack placeholder-shell direction just because it exists.
+- Do not treat `packages/tanstack-start/src/views/TanStackAdminPage.tsx` as acceptable final architecture.
+- Do not keep Next-only assumptions in `ui` when they can be moved behind an adapter boundary.
+
+## Success Criteria
+
+Phase 1 is successful when:
+
+1. the first admin page can be described and loaded through a shared root/bootstrap architecture without requiring a TanStack-only template workaround
+2. `ui` owns shared route/view/template contracts rather than direct framework rendering decisions
+3. `next` clearly owns async RSC and request/runtime execution for the migrated bootstrap slice
+4. all remaining built-in admin views, including login, dashboard, list, edit/document, account, and version flows, are clearly staged behind the same boundary model
+5. the TanStack target architecture is explicitly per-view and folder-based, not centered on one monolithic admin page file
+
+The overall effort is successful when **every built-in admin view follows the proven dashboard pattern**:
+
+6. the shared client UI component for each view lives in `@payloadcms/ui` and is publicly exported from the package boundary (not via deep relative source paths)
+7. `@payloadcms/next` is a thin adapter per view: RSC data fetching on the server, then pass-through to the shared `ui` component
+8. `@payloadcms/tanstack-start` is a thin adapter per view: server-function or `pageState` data fetching, then pass-through to the same shared `ui` component
+9. both frameworks produce the same visual and interactive result for every view
+10. no adapter package contains a bespoke reimplementation of view behavior that already exists in `@payloadcms/ui`
+
+## Recommended First Implementation Steps
+
+1. identify the minimum shared root/bootstrap components required to load the first admin page
+2. introduce a renderer abstraction beside the current `RenderServerComponent`
+3. refactor root rendering so `ui` builds a shared bootstrap/page descriptor
+4. implement the first Next async wrapper/adapter for that descriptor
+5. collapse TanStack first-page loading onto the shared root/bootstrap contracts instead of extending custom wrappers
+6. define the per-view TanStack adapter folders that should replace `TanStackAdminPage.tsx`
+7. continue view-by-view with auth, dashboard, list, folder, document/edit, account, and version adapters only after that bootstrap path is stable
diff --git a/docs/plans/2026-04-03-serializable-server-functions.md b/docs/plans/2026-04-03-serializable-server-functions.md
new file mode 100644
index 00000000000..063f5138cac
--- /dev/null
+++ b/docs/plans/2026-04-03-serializable-server-functions.md
@@ -0,0 +1,788 @@
+# Serializable Server Functions Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Split each `render-*` server function handler into a serializable data function (new default in `@payloadcms/ui`) and an RSC rendering wrapper (override in `@payloadcms/next`), making the shared handlers framework-agnostic while preserving Next.js RSC behavior exactly.
+
+**Architecture:** Two-tier handler pattern — `@payloadcms/ui` defaults return serializable data (safe for any transport including TanStack Start's seroval). `@payloadcms/next` overrides those defaults with RSC handlers via `extraServerFunctions`, producing the same `{ List: ReactNode }` / `{ Document: ReactNode }` output it does today. No behavioral change for Next.js users.
+
+**Tech Stack:** TypeScript, React 19, Payload CMS monorepo (`pnpm` + Turbo), `@payloadcms/ui`, `@payloadcms/next`, `@payloadcms/tanstack-start`
+
+---
+
+## Background
+
+### Problem
+
+The `render-list`, `render-document`, and `render-widget` server function handlers in `@payloadcms/ui` return React nodes (JSX). This works in Next.js because the Flight protocol serializes JSX natively. In TanStack Start, the `createServerFn` transport uses `seroval` for serialization, which crashes on `Symbol(react.transitional.element)`.
+
+### Current Flow (Broken in TanStack Start)
+
+```
+Client component calls serverFunction({ name: 'render-list', args })
+ → TanStack createServerFn handler
+ → handleServerFunctions
+ → dispatchServerFunction
+ → renderListHandler → renderListView → { List: ReactNode }
+ → seroval tries to serialize ReactNode → CRASH
+```
+
+### Target Flow
+
+```
+TanStack Start (data-only defaults):
+ dispatchServerFunction → fetchListServerFnData → { listData, preferences }
+ → seroval serializes plain objects → OK
+
+Next.js (RSC override):
+ dispatchServerFunction({ extraServerFunctions: rscServerFunctions })
+ → renderListRSCHandler → renderListView → { List: ReactNode }
+ → Flight serializes ReactNode → OK (unchanged)
+```
+
+### Architecture Diagram
+
+```mermaid
+flowchart TB
+ subgraph UI["@payloadcms/ui (shared)"]
+ DataFn["Data-only handlers (NEW DEFAULT)\nrender-list → fetchListServerFnData\nrender-document → fetchDocumentServerFnData\nrender-widget → fetchWidgetServerFnData"]
+ RSCFn["RSC handlers (EXPORTED separately)\nrenderListRSCHandler\nrenderDocumentRSCHandler\nrenderWidgetRSCHandler"]
+ end
+
+ subgraph Next["@payloadcms/next"]
+ NextHSF["handleServerFunctions\npasses RSC handlers as extraServerFunctions"]
+ end
+
+ subgraph Tanstack["@payloadcms/tanstack-start"]
+ TanstackHSF["handleServerFunctions\nuses data-only defaults"]
+ end
+
+ RSCFn -->|"extraServerFunctions override"| NextHSF
+ DataFn -->|"baseServerFunctions default"| TanstackHSF
+```
+
+---
+
+## Task 1: Extract `fetchListServerFnData` in `@payloadcms/ui`
+
+**Files:**
+
+- Create: `packages/ui/src/views/List/fetchListServerFnData.ts`
+- Modify: `packages/ui/src/utilities/handleServerFunctions.ts`
+- Reference: `packages/ui/src/views/List/handleServerFunction.tsx` (current RSC handler)
+- Reference: `packages/ui/src/views/List/fetchListViewData.ts` (existing data function)
+
+**Step 1: Create the data-only handler**
+
+Create `packages/ui/src/views/List/fetchListServerFnData.ts`:
+
+```ts
+import type {
+ CollectionPreferences,
+ ResolvedFilterOptions,
+ ServerFunction,
+ VisibleEntities,
+} from 'payload'
+
+import { canAccessAdmin, isEntityHidden, UnauthorizedError } from 'payload'
+import { applyLocaleFiltering } from 'payload/shared'
+
+import type { RenderListServerFnArgs } from '../../elements/ListDrawer/types.js'
+
+import { getClientConfig } from '../../utilities/getClientConfig.js'
+import { fetchListViewData } from './fetchListViewData.js'
+
+export type RenderListDataResult = {
+ listData: {
+ collectionPreferences: CollectionPreferences
+ collectionSlug: string
+ columns: unknown[]
+ data: unknown
+ hasCreatePermission: boolean
+ hasDeletePermission: boolean
+ hasTrashPermission: boolean
+ isGroupBy: boolean
+ newDocumentURL: string
+ notFoundDocId: null | string
+ orderableFieldName: string | undefined
+ query: Record
+ queryPreset: unknown
+ queryPresetPermissions: unknown
+ resolvedFilterOptions: Record
+ select: unknown
+ staticDescription: unknown
+ viewType: unknown
+ where: unknown
+ }
+ preferences: CollectionPreferences
+}
+
+export const fetchListServerFnData: ServerFunction<
+ RenderListServerFnArgs,
+ Promise
+> = async (args) => {
+ const {
+ collectionSlug,
+ cookies,
+ enableRowSelections,
+ locale,
+ overrideEntityVisibility,
+ permissions,
+ query,
+ req,
+ req: {
+ i18n,
+ payload,
+ payload: { config },
+ user,
+ },
+ searchParams,
+ trash,
+ viewType,
+ } = args
+
+ if (!req.user) {
+ throw new UnauthorizedError()
+ }
+
+ await canAccessAdmin({ req })
+
+ const clientConfig = getClientConfig({
+ config,
+ i18n,
+ importMap: payload.importMap,
+ user,
+ })
+ await applyLocaleFiltering({ clientConfig, config, req })
+
+ const preferencesKey = `collection-${collectionSlug}`
+
+ const preferences = await payload
+ .find({
+ collection: 'payload-preferences',
+ depth: 0,
+ limit: 1,
+ where: {
+ and: [
+ { key: { equals: preferencesKey } },
+ { 'user.relationTo': { equals: user.collection } },
+ { 'user.value': { equals: user.id } },
+ ],
+ },
+ })
+ .then((res) => res.docs[0]?.value as CollectionPreferences)
+
+ const visibleEntities: VisibleEntities = {
+ collections: payload.config.collections
+ .map(({ slug, admin: { hidden } }) =>
+ !isEntityHidden({ hidden, user }) ? slug : null,
+ )
+ .filter(Boolean),
+ globals: payload.config.globals
+ .map(({ slug, admin: { hidden } }) =>
+ !isEntityHidden({ hidden, user }) ? slug : null,
+ )
+ .filter(Boolean),
+ }
+
+ const listData = await fetchListViewData({
+ clientConfig,
+ collectionSlug,
+ overrideEntityVisibility,
+ permissions,
+ query,
+ req,
+ searchParams: searchParams ?? {},
+ trash,
+ viewType: viewType ?? 'list',
+ visibleEntities,
+ })
+
+ // Convert Map to Record for serialization
+ const serializedFilterOptions: Record = {}
+ for (const [key, value] of listData.resolvedFilterOptions) {
+ serializedFilterOptions[key] = value
+ }
+
+ return {
+ listData: {
+ ...listData,
+ query: listData.query as unknown as Record,
+ resolvedFilterOptions: serializedFilterOptions,
+ },
+ preferences,
+ }
+}
+```
+
+**Step 2: Verify the file compiles**
+
+Run: `cd packages/ui && npx tsc --noEmit src/views/List/fetchListServerFnData.ts 2>&1 | head -20`
+Expected: No errors (or only pre-existing ones)
+
+**Step 3: Commit**
+
+```bash
+git add packages/ui/src/views/List/fetchListServerFnData.ts
+git commit -m "feat(ui): extract fetchListServerFnData for serializable render-list"
+```
+
+---
+
+## Task 2: Extract `fetchDocumentServerFnData` in `@payloadcms/ui`
+
+**Files:**
+
+- Create: `packages/ui/src/views/Document/fetchDocumentServerFnData.ts`
+- Reference: `packages/tanstack-start/src/views/Root/serverFunctions.ts` (source of `tanstackDocumentStateHandler`)
+
+**Step 1: Create the data-only handler**
+
+Move the `tanstackDocumentStateHandler` logic from `packages/tanstack-start/src/views/Root/serverFunctions.ts` into `packages/ui/src/views/Document/fetchDocumentServerFnData.ts`. The function:
+
+- Accepts the same args (collectionSlug, docID, documentSubViewType, globalSlug, etc.)
+- Calls `getDocumentData`, `getDocPreferences`, `getDocumentPermissions`, `buildFormState`, `getIsLocked`, `getVersions`, `handleLivePreview`, `handlePreview`
+- Returns `RenderDocumentDataResult` — all serializable
+
+The key imports that need updating (these are already in `@payloadcms/ui`):
+
+- `reduceToSerializableFields` from `@payloadcms/ui/shared`
+- `buildFormState` from `@payloadcms/ui/utilities/buildFormState`
+- `getDocPreferences` from `@payloadcms/ui/views/Document/getDocPreferences`
+- `getDocumentData` from `@payloadcms/ui/views/Document/getDocumentData`
+- `getDocumentPermissions` from `@payloadcms/ui/views/Document/getDocumentPermissions`
+- `getIsLocked` from `@payloadcms/ui/views/Document/getIsLocked`
+- `getVersions` from `@payloadcms/ui/views/Document/getVersions`
+- `handleLivePreview` from `@payloadcms/ui/utilities/handleLivePreview`
+- `handlePreview` from `@payloadcms/ui/utilities/handlePreview`
+
+All these are already in `@payloadcms/ui`, so the function can live there with local imports.
+
+Export type:
+
+```ts
+export type RenderDocumentDataResult = {
+ apiURL?: string
+ collectionSlug?: string
+ currentEditor?: unknown
+ data?: Data
+ docPermissions?: Record
+ globalSlug?: string
+ hasDeletePermission?: boolean
+ hasPublishedDoc: boolean
+ hasPublishPermission?: boolean
+ hasSavePermission?: boolean
+ hasTrashPermission?: boolean
+ id?: number | string
+ initialState?: DocumentViewClientProps['formState']
+ isEditing?: boolean
+ isLivePreviewEnabled?: boolean
+ isLocked: boolean
+ isPreviewEnabled?: boolean
+ isTrashed?: boolean
+ lastUpdateTime: number
+ livePreviewBreakpoints?: unknown[]
+ livePreviewURL?: string
+ mostRecentVersionIsAutosaved: boolean
+ preferences?: DocumentPreferences
+ previewURL?: string
+ redirectURL?: string
+ typeofLivePreviewURL?: 'function' | 'string'
+ unpublishedVersionCount: number
+ versionCount: number
+}
+```
+
+**Step 2: Verify the file compiles**
+
+Run: `cd packages/ui && npx tsc --noEmit src/views/Document/fetchDocumentServerFnData.ts 2>&1 | head -20`
+Expected: No errors
+
+**Step 3: Commit**
+
+```bash
+git add packages/ui/src/views/Document/fetchDocumentServerFnData.ts
+git commit -m "feat(ui): extract fetchDocumentServerFnData for serializable render-document"
+```
+
+---
+
+## Task 3: Extract `fetchWidgetServerFnData` in `@payloadcms/ui`
+
+**Files:**
+
+- Create: `packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/fetchWidgetServerFnData.ts`
+- Reference: `packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.ts` (current RSC handler)
+
+**Step 1: Create the data-only handler**
+
+```ts
+import type { PayloadComponent, ServerFunction } from 'payload'
+
+import type { RenderWidgetServerFnArgs } from './renderWidgetServerFn.js'
+
+import { extractLocaleData } from '../utils/localeUtils.js'
+
+export type RenderWidgetDataResult = {
+ Component?: PayloadComponent
+ error?: string
+ widgetData?: Record
+ widgetSlug: string
+}
+
+export const fetchWidgetServerFnData: ServerFunction<
+ RenderWidgetServerFnArgs,
+ Promise
+> = async ({ req, widgetData, widgetSlug }) => {
+ if (!req.user) {
+ throw new Error('Unauthorized')
+ }
+
+ const { widgets } = req.payload.config.admin.dashboard
+ const widgetConfig = widgets.find((widget) => widget.slug === widgetSlug)
+
+ if (!widgetConfig) {
+ return { error: `Widget "${widgetSlug}" not found`, widgetSlug }
+ }
+
+ const localeFilteredData = widgetConfig.fields?.length
+ ? extractLocaleData(
+ widgetData || {},
+ req.locale || 'en',
+ widgetConfig.fields as any,
+ )
+ : widgetData || {}
+
+ return {
+ Component: widgetConfig.Component,
+ widgetData: localeFilteredData,
+ widgetSlug,
+ }
+}
+```
+
+**Step 2: Verify the file compiles**
+
+Run: `cd packages/ui && npx tsc --noEmit src/views/Dashboard/Default/ModularDashboard/renderWidget/fetchWidgetServerFnData.ts 2>&1 | head -20`
+Expected: No errors
+
+**Step 3: Commit**
+
+```bash
+git add packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/fetchWidgetServerFnData.ts
+git commit -m "feat(ui): extract fetchWidgetServerFnData for serializable render-widget"
+```
+
+---
+
+## Task 4: Update `baseServerFunctions` and export RSC handlers
+
+**Files:**
+
+- Modify: `packages/ui/src/utilities/handleServerFunctions.ts`
+
+**Step 1: Update the dispatch map**
+
+In `packages/ui/src/utilities/handleServerFunctions.ts`:
+
+1. Import the new data-only handlers
+2. Rename current RSC handlers in the import to clarify their role
+3. Replace `render-list`, `render-document`, `render-widget` in `baseServerFunctions` with data-only handlers
+4. Export the RSC handlers separately as `rscServerFunctions`
+
+```ts
+// New imports
+import { fetchDocumentServerFnData } from '../views/Document/fetchDocumentServerFnData.js'
+import { fetchWidgetServerFnData } from '../views/Dashboard/Default/ModularDashboard/renderWidget/fetchWidgetServerFnData.js'
+import { fetchListServerFnData } from '../views/List/fetchListServerFnData.js'
+
+// Existing imports (renamed for clarity)
+import { renderListHandler as renderListRSCHandler } from '../views/List/handleServerFunction.js'
+import { renderDocumentHandler as renderDocumentRSCHandler } from '../views/Document/handleServerFunction.js'
+import { renderWidgetHandler as renderWidgetRSCHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.js'
+
+// baseServerFunctions uses data-only handlers as defaults
+export const baseServerFunctions: Record = {
+ // ... unchanged handlers ...
+ 'render-document': fetchDocumentServerFnData, // was renderDocumentHandler
+ 'render-list': fetchListServerFnData, // was renderListHandler
+ 'render-widget': fetchWidgetServerFnData, // was renderWidgetHandler
+ // ... unchanged handlers ...
+}
+
+// RSC handlers for frameworks that support React element serialization (Next.js Flight)
+export const rscServerFunctions: Record = {
+ 'render-document': renderDocumentRSCHandler,
+ 'render-list': renderListRSCHandler,
+ 'render-widget': renderWidgetRSCHandler,
+}
+```
+
+**Step 2: Verify the file compiles**
+
+Run: `cd packages/ui && npx tsc --noEmit src/utilities/handleServerFunctions.ts 2>&1 | head -20`
+Expected: No errors
+
+**Step 3: Commit**
+
+```bash
+git add packages/ui/src/utilities/handleServerFunctions.ts
+git commit -m "feat(ui): default baseServerFunctions to data-only, export rscServerFunctions separately"
+```
+
+---
+
+## Task 5: Update `@payloadcms/next` to pass RSC overrides
+
+**Files:**
+
+- Modify: `packages/next/src/utilities/handleServerFunctions.ts`
+
+**Step 1: Import and pass RSC handlers**
+
+```ts
+import type { ServerFunctionHandler } from 'payload'
+
+import {
+ dispatchServerFunction,
+ rscServerFunctions,
+} from '@payloadcms/ui/utilities/handleServerFunctions'
+import { notFound, redirect } from 'next/navigation.js'
+
+import { initReq } from './initReq.js'
+
+export const handleServerFunctions: ServerFunctionHandler = async (args) => {
+ const {
+ name: fnKey,
+ args: fnArgs,
+ config: configPromise,
+ importMap,
+ serverFunctions: extraServerFunctions,
+ } = args
+
+ const { cookies, locale, permissions, req } = await initReq({
+ configPromise,
+ importMap,
+ key: 'RootLayout',
+ })
+
+ const augmentedArgs = {
+ ...fnArgs,
+ cookies,
+ importMap,
+ locale,
+ notFound: () => notFound(),
+ permissions,
+ redirect: (url: string) => redirect(url),
+ req,
+ }
+
+ return dispatchServerFunction({
+ name: fnKey,
+ augmentedArgs,
+ extraServerFunctions: {
+ ...rscServerFunctions,
+ ...extraServerFunctions,
+ },
+ })
+}
+```
+
+The only change is importing `rscServerFunctions` and merging them into `extraServerFunctions`. RSC handlers take priority because they come first in the spread; user-provided `extraServerFunctions` can still override.
+
+**Step 2: Verify Next.js admin panel still works**
+
+Run: `pnpm run dev` (or `pnpm run dev fields` or similar test suite)
+Navigate to: `http://localhost:3000/admin`
+Expected: Dashboard, list views, document views, list drawers, widget rendering all work exactly as before.
+
+**Step 3: Commit**
+
+```bash
+git add packages/next/src/utilities/handleServerFunctions.ts
+git commit -m "feat(next): pass rscServerFunctions as extraServerFunctions to preserve RSC rendering"
+```
+
+---
+
+## Task 6: Update `ListDrawer/DrawerContent.tsx` to handle data responses
+
+**Files:**
+
+- Modify: `packages/ui/src/elements/ListDrawer/DrawerContent.tsx`
+
+**Step 1: Update the refresh callback**
+
+The drawer currently does:
+
+```ts
+const result = (await serverFunction({
+ name: 'render-list',
+ args,
+})) as RenderListServerFnReturnType
+setListView(result?.List || null)
+```
+
+Update to detect whether the response is RSC (has `List`) or data-only (has `listData`):
+
+```ts
+const result = await serverFunction({ name: 'render-list', args })
+
+if (result?.List) {
+ // RSC path (Next.js) — use pre-rendered JSX directly
+ setListView(result.List)
+} else if (result?.listData) {
+ // Data path (TanStack Start / non-RSC) — render client-side from data
+ setListView(
+
+
+
+ )
+} else {
+ setListView(null)
+}
+```
+
+Import `DefaultListView` from `../../views/List/index.js` and `ListQueryProvider` from `../../providers/ListQuery/index.js`.
+
+**Step 2: Verify list drawers still work in Next.js**
+
+Run: `pnpm run dev`
+Navigate to a document with a relationship field → open the list drawer
+Expected: Drawer shows the collection list with full table, filters, etc. (RSC path is used)
+
+**Step 3: Commit**
+
+```bash
+git add packages/ui/src/elements/ListDrawer/DrawerContent.tsx
+git commit -m "feat(ui): ListDrawer handles both RSC and data-only render-list responses"
+```
+
+---
+
+## Task 7: Update `RenderWidget.tsx` to handle data responses
+
+**Files:**
+
+- Modify: `packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/RenderWidget.tsx`
+
+**Step 1: Update the render callback**
+
+Currently:
+
+```ts
+const result = (await serverFunction({
+ name: 'render-widget',
+ args,
+})) as RenderWidgetServerFnReturnType
+setComponent(result.component)
+```
+
+Update to handle both RSC and data responses. For the data path, use `importMap` to resolve the widget component and render it client-side:
+
+```ts
+const result = await serverFunction({ name: 'render-widget', args })
+
+if (result?.component) {
+ // RSC path — pre-rendered JSX
+ setComponent(result.component)
+} else if (result?.Component) {
+ // Data path — resolve component from importMap and render client-side
+ // The Component is a PayloadComponent (string path like "MyWidget#default")
+ // Resolve it from the import map and create the element
+ const { resolveComponentFromImportMap } = await import(
+ '@payloadcms/ui/utilities/resolveComponentFromImportMap'
+ )
+ const ResolvedComponent = resolveComponentFromImportMap(
+ result.Component,
+ importMap,
+ )
+ if (ResolvedComponent) {
+ setComponent(
+ React.createElement(ResolvedComponent, { widgetData: result.widgetData }),
+ )
+ } else {
+ setComponent(
+ React.createElement(
+ 'div',
+ null,
+ `Widget component not found: ${result.widgetSlug}`,
+ ),
+ )
+ }
+} else if (result?.error) {
+ setComponent(React.createElement('div', null, result.error))
+}
+```
+
+Note: `RenderWidget` needs access to `importMap` from context. Check if there's an existing `useImportMap` hook or if it needs to be threaded through props/context.
+
+**Step 2: Verify dashboard widgets render in Next.js**
+
+Run: `pnpm run dev`
+Navigate to: `http://localhost:3000/admin` (dashboard)
+Expected: Widgets render as before
+
+**Step 3: Commit**
+
+```bash
+git add packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/RenderWidget.tsx
+git commit -m "feat(ui): RenderWidget handles both RSC and data-only render-widget responses"
+```
+
+---
+
+## Task 8: Clean up `@payloadcms/tanstack-start`
+
+**Files:**
+
+- Modify: `packages/tanstack-start/src/utilities/handleServerFunctions.ts`
+- Modify: `packages/tanstack-start/src/views/Root/serverFunctions.ts`
+- Modify: `packages/tanstack-start/src/views/Root/getPageState.ts`
+
+**Step 1: Simplify `handleServerFunctions.ts`**
+
+Remove:
+
+- The `renderToString` import and `serializeReactNodes` function
+- The `SERVER_FNS_WITH_REACT_NODES` set
+- The `tanstack-document-state` special case (the default `render-document` now returns serializable data)
+- The `isRenderedHTML` / `RenderedHTML` exports
+
+The file becomes:
+
+```ts
+import type { ServerFunctionHandler } from 'payload'
+
+import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions'
+import { notFound, redirect } from '@tanstack/react-router'
+
+import { TANSTACK_INVALIDATE } from '../views/Root/serverFunctions.js'
+import { initReq } from './initReq.js'
+
+export const handleServerFunctions: ServerFunctionHandler = async (args) => {
+ const { name: fnKey, args: fnArgs, config, importMap, serverFunctions } = args
+ const invalidateArgs = fnArgs as
+ | { __tanstackInvalidate?: boolean }
+ | undefined
+
+ const { cookies, locale, permissions, req } = await initReq({
+ config,
+ importMap,
+ key: 'RootLayout',
+ })
+
+ const augmentedArgs = {
+ ...fnArgs,
+ cookies,
+ importMap,
+ locale,
+ notFound: () => {
+ throw notFound()
+ },
+ permissions,
+ redirect: (url: string) => {
+ throw redirect({ to: url })
+ },
+ req,
+ }
+
+ if (
+ (fnKey === 'render-document' || fnKey === 'render-list') &&
+ invalidateArgs?.__tanstackInvalidate === true
+ ) {
+ return TANSTACK_INVALIDATE
+ }
+
+ return dispatchServerFunction({
+ name: fnKey,
+ augmentedArgs,
+ extraServerFunctions: serverFunctions,
+ })
+}
+```
+
+**Step 2: Simplify `serverFunctions.ts`**
+
+Remove the `tanstackDocumentStateHandler` function (now lives in `@payloadcms/ui` as `fetchDocumentServerFnData`). Keep only `TANSTACK_INVALIDATE` and the type.
+
+**Step 3: Update `getPageState.ts`**
+
+Update the import for the document data function — change from local `tanstackDocumentStateHandler` to the shared `fetchDocumentServerFnData` from `@payloadcms/ui`.
+
+**Step 4: Remove `renderToString` from `TanStackRootProvider`**
+
+Remove the `hydrateRenderedHTML` function and the `isRenderedHTML` check from `packages/tanstack-start/src/layouts/Root/TanStackRootProvider.tsx`. The `__tanstack_rendered_html` marker is no longer needed.
+
+**Step 5: Commit**
+
+```bash
+git add packages/tanstack-start/
+git commit -m "refactor(tanstack-start): remove renderToString workaround, use shared data-only handlers"
+```
+
+---
+
+## Task 9: Verify Next.js admin panel
+
+**Step 1: Start the Next.js dev server**
+
+Run: `pnpm run dev`
+Navigate to: `http://localhost:3000/admin`
+
+**Step 2: Test critical flows**
+
+- Dashboard: widgets render
+- List view: table, filters, column state, bulk actions work
+- Document view: form renders, save works
+- List drawer: open a relationship field's drawer, verify list loads
+- Account view: renders correctly
+
+Expected: All flows work identically to before the refactor.
+
+**Step 3: Run integration tests**
+
+Run: `pnpm run test:int admin-root`
+Expected: All tests pass
+
+---
+
+## Task 10: Verify TanStack Start admin panel
+
+**Step 1: Start the TanStack Start dev server**
+
+Run: `pnpm dev:tanstack`
+
+**Step 2: Test with Playwright MCP**
+
+Navigate to: `http://localhost:3000/admin`
+
+- Dashboard: no seroval errors, widgets render (data path)
+- List view: shell renders from loader data, server function returns serializable data
+- Document view: form renders from loader data
+- Console: no `Seroval Error` messages
+
+**Step 3: Commit summary**
+
+```bash
+git add -A
+git commit -m "feat: serializable server functions — data-only defaults with RSC override in next.js"
+```
diff --git a/docs/plans/architecture-framework-agnostic-admin.md b/docs/plans/architecture-framework-agnostic-admin.md
new file mode 100644
index 00000000000..20dfd7f4e22
--- /dev/null
+++ b/docs/plans/architecture-framework-agnostic-admin.md
@@ -0,0 +1,414 @@
+# Architecture: Framework-Agnostic Admin UI
+
+## The Idea in One Sentence
+
+Payload's admin UI lives in `@payloadcms/ui` as shared React components. Framework packages (`@payloadcms/next`, `@payloadcms/tanstack-start`) are thin adapters that fetch data and wire routing — they don't own UI.
+
+## Three Layers
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ Framework Adapter │
+│ (@payloadcms/next or @payloadcms/tanstack-start) │
+│ │
+│ - Request init (headers, cookies) │
+│ - Data fetching (RSC / server functions / loaders) │
+│ - Navigation primitives (redirect, notFound, Link) │
+│ - Route handlers (REST, GraphQL) │
+└────────────────────────┬────────────────────────────────┘
+ │ passes data as props
+┌────────────────────────▼────────────────────────────────┐
+│ Shared UI (@payloadcms/ui) │
+│ │
+│ - All React components (client + server) │
+│ - View composition (Dashboard, List, Document, etc.) │
+│ - Templates (Default, Minimal) │
+│ - Server function handlers (form-state, render-list…) │
+│ - RouterProvider contract (framework-neutral hooks) │
+└────────────────────────┬────────────────────────────────┘
+ │
+┌────────────────────────▼────────────────────────────────┐
+│ Core (payload) │
+│ │
+│ - Collections, fields, hooks, access control │
+│ - Local API, database adapters │
+│ - AdminAdapter interface + createAdminAdapter helper │
+│ - Config types │
+└─────────────────────────────────────────────────────────┘
+```
+
+## AdminAdapter Interface
+
+Every framework implements this contract, similar to how database adapters work:
+
+```ts
+// Defined in payload core
+interface BaseAdminAdapter {
+ name: string
+
+ initReq: (args) => Promise // Read request context
+ getCookie / setCookie / deleteCookie // Cookie management
+ handleServerFunctions: ServerFunctionHandler // Dispatch server functions
+ notFound: () => never // Framework-specific 404
+ redirect: (url: string) => never // Framework-specific redirect
+ RouterProvider: React.ComponentType // Client-side router context
+ createRouteHandlers: () => RouteHandlers // REST + GraphQL routes
+}
+```
+
+Usage in config:
+
+```ts
+export default buildConfig({
+ db: postgresAdapter({ ... }),
+ admin: {
+ adapter: nextAdapter(), // or tanstackStartAdapter()
+ },
+})
+```
+
+## RouterProvider: One Hook API, Two Implementations
+
+`@payloadcms/ui` defines a `RouterProvider` context. Each adapter fills it with its own primitives. All UI components use framework-neutral hooks.
+
+```
+@payloadcms/ui defines: useRouter, usePathname, useSearchParams, useParams, Link
+ ▲
+ ┌───────────────────────────────┼──────────────────────────────────┐
+ │ │ │
+ NextRouterProvider TanStackRouterProvider (future adapter)
+ wraps next/navigation wraps @tanstack/react-router wraps ???
+ wraps next/link wraps TanStack Link
+```
+
+**Next.js adapter** (`packages/next/src/adapter/RouterProvider.tsx`):
+
+```tsx
+const NextRouterProvider = ({ children }) => {
+ const nextRouter = useNextRouter()
+ const pathname = useNextPathname()
+ // ... map to RouterContextType
+ return {children}
+}
+```
+
+**TanStack adapter** (`packages/tanstack-start/src/adapter/RouterProvider.tsx`):
+
+```tsx
+function TanStackRouterProvider({ children }) {
+ const router = useRouter()
+ const location = useLocation()
+ // ... map to RouterContextType
+ return {children}
+}
+```
+
+Result: `@payloadcms/ui` has zero `next/*` or `@tanstack/*` imports.
+
+## The Pattern: Two Layers per View
+
+Every built-in admin view is split into two shared layers, plus a thin adapter wrapper:
+
+1. **Data function** (shared, `@payloadcms/ui`) — fetches and returns plain serializable data. No React nodes.
+2. **Builder / render function** (shared, `@payloadcms/ui`) — takes the data, produces rendered JSX with providers and slots.
+3. **Adapter wrapper** (per-framework) — wires the data into the component using the framework's data-fetching primitive (RSC, loader, server function).
+
+The data function is the key abstraction. Both Next RSC and TanStack loaders call it to get the same serializable data before any rendering happens.
+
+### Dashboard Example (Reference)
+
+**1. Shared UI** — `@payloadcms/ui` owns both the builder and the client components:
+
+```tsx
+// packages/ui/src/views/Dashboard/index.tsx
+// renderDashboardView — async builder that fetches global data,
+// assembles nav groups, and returns { Dashboard: ReactNode }
+
+// packages/ui/src/views/Dashboard/Default/ModularDashboard/
+// ModularDashboardClient, RenderWidget, etc.
+// Pure React client components. No framework dependency.
+```
+
+**2. Next.js adapter** — RSC wrapper that calls the shared builder:
+
+```tsx
+// packages/next/src/views/Dashboard/index.tsx
+import { renderDashboardView } from '@payloadcms/ui/views/Dashboard'
+
+export const DashboardView = async (args) => {
+ const { Dashboard } = await renderDashboardView(args)
+ return Dashboard
+}
+```
+
+**3. TanStack adapter** — renders immediately from loader-supplied data:
+
+```tsx
+// packages/tanstack-start/src/views/Dashboard/index.tsx
+export function DashboardView({ pageState }) {
+ const clientLayout = layoutItems.map(item => ({
+ component: ,
+ item: { id: item.id, ... },
+ }))
+
+ return
+}
+```
+
+Both frameworks render the same `ModularDashboardClient`. Same drag-and-drop, same widget editing, same visual result. No `useEffect`, no loading spinner.
+
+### Data-First View Loading
+
+Views that are more complex than the Dashboard (List, Document, Account) use a **data-first** pattern: the TanStack loader fetches serializable data in `getPageState`, and the adapter component renders immediately from that data.
+
+```mermaid
+sequenceDiagram
+ participant Loader as TanStack Loader (getPageState)
+ participant DataFn as Shared Data Function
+ participant Comp as Adapter Component
+
+ Loader->>DataFn: fetchListViewData(req, collection, query)
+ DataFn-->>Loader: { docs, columns, permissions, ... }
+ Note over Loader: Serialized into pageData.list
+ Loader->>Comp: pageState.pageData.list
+ Note over Comp: Renders shell immediately — no LoadingOverlay
+ Note over Comp: Table/slots load via server function in parallel
+```
+
+The shared data functions live in `@payloadcms/ui` and are called by both frameworks:
+
+| View | Data function | Location |
+| ------------ | --------------------------------------- | ------------------------------------------------------------------ |
+| List / Trash | `fetchListViewData()` | `@payloadcms/ui/views/List/fetchListViewData` |
+| Document | `tanstackDocumentStateHandler()` | `@payloadcms/tanstack-start` (to be extracted to `@payloadcms/ui`) |
+| Account | Same as Document (with `account: true`) | Same |
+
+`getPageState` calls the appropriate data function based on `viewType`:
+
+```ts
+// In getPageState, after determining viewType:
+if (pageViewType === 'list' || pageViewType === 'trash') {
+ pageData = { ...pageData, list: await fetchListViewData({ req, ... }) }
+}
+if (pageViewType === 'document') {
+ pageData = { ...pageData, document: await tanstackDocumentStateHandler({ req, ... }) }
+}
+if (pageViewType === 'account') {
+ pageData = { ...pageData, document: await tanstackDocumentStateHandler({ req, account: true, ... }) }
+}
+```
+
+### Structural Pattern
+
+Every view follows this split:
+
+- `@payloadcms/ui` exports a shared **data function** (`fetchListViewData`) that returns serializable data, and an async **builder** (`renderListView`) that composes data + JSX
+- `@payloadcms/next` owns the **RSC wrapper** that calls the builder and handles framework errors (`notFound`, `redirect`)
+- `@payloadcms/tanstack-start` owns a **data-first wrapper** that reads loader data from `pageState.pageData`, renders the view shell immediately, and loads non-serializable parts (Table, slots) via server functions
+
+The data function is the shared code. The builder composes data + rendering. The adapter wires framework-specific data loading.
+
+### List View Example
+
+**1. Shared UI** — `@payloadcms/ui` owns `fetchListViewData` (data layer), `renderListView` (builder), and `DefaultListView` (client component).
+
+`fetchListViewData` extracts all data-fetching logic: permission checks, query/preference resolution, `payload.find`, version enrichment, filter options, and permission computation. Returns only serializable data. `renderListView` calls it internally, then handles table rendering, filters, and JSX assembly.
+
+**2. Next.js adapter** — calls the shared builder, handles `notFound()`:
+
+```tsx
+// packages/next/src/views/List/index.tsx
+export const ListView = async (args) => {
+ try {
+ const { List } = await renderListView({ ...args })
+ return List
+ } catch (error) {
+ if (error.message === 'not-found') notFound()
+ }
+}
+```
+
+**3. TanStack adapter** — renders shell from loader data, loads Table via server function:
+
+```tsx
+// packages/tanstack-start/src/views/List/index.tsx
+export function ListView({ pageState }) {
+ const listData = pageState.pageData?.list // from loader
+ const [renderedList, setRenderedList] = useState(null)
+
+ // Server function loads Table/columnState/filters/slots in parallel
+ useEffect(() => { serverFunction('render-list', ...).then(...) }, [...])
+
+ // When server function completes, swap to full rendered view
+ if (renderedList) return renderedList
+
+ // Immediate render from loader data — no LoadingOverlay
+ return (
+
+
+
+ )
+}
+```
+
+The user sees the list header, controls, and pagination immediately. The table body appears when the server function returns.
+
+### Document View Example
+
+**1. Next.js** — calls `renderDocument` server-side via RSC:
+
+```
+Next.js: RSC page → renderDocument() →
+```
+
+**2. TanStack** — renders document providers directly from loader data:
+
+```tsx
+// packages/tanstack-start/src/views/Document/index.tsx
+export function DocumentView({ pageState }) {
+ const documentData = pageState.pageData?.document // from loader
+
+ // Version subviews still use server function fallback
+ if (!documentData || isVersionView) return
+
+ return (
+
+
+
+
+
+ )
+}
+```
+
+No loading overlay. The document form, permissions, and editing state render immediately from the serialized loader data. The `tanstackDocumentStateHandler` (which already produced serializable data) now runs in the loader instead of via `useEffect`.
+
+### The Hybrid: Non-Serializable Parts
+
+Some view parts are `React.ReactNode` and cannot pass through JSON serialization:
+
+| Non-serializable part | Where it comes from | How TanStack handles it |
+| -------------------------------------- | -------------------------------------- | -------------------------------------------------- |
+| `Table` (list rows) | `renderTable()` / `buildColumnState()` | Server function after mount (like `RenderWidget`) |
+| `renderedFilters` | `renderFilters()` | Server function after mount |
+| List slots (`AfterList`, `BeforeList`) | `renderListViewSlots()` | Server function after mount |
+| Document slots (Save, Publish) | `renderDocumentSlots()` | Provider-based (already in `DocumentInfoProvider`) |
+
+This follows the same proven pattern as Dashboard widgets: metadata loads in the loader, heavy JSX renders on demand via server function. The key improvement is eliminating the full-page `LoadingOverlay` — the view shell renders immediately.
+
+## Server Functions: Shared Logic, Framework Transport
+
+Reusable server-side logic lives in `@payloadcms/ui`. Each framework provides its own transport:
+
+```
+@payloadcms/ui registers handlers:
+ 'form-state' → buildFormStateHandler
+ 'render-list' → renderListHandler
+ 'render-document' → renderDocumentHandler
+ 'render-widget' → renderWidgetHandler
+ 'table-state' → buildTableStateHandler
+ ...
+
+Next.js calls them via: server actions ('use server')
+TanStack calls them via: createServerFn() from @tanstack/start
+(future) could call via: plain REST endpoints
+```
+
+Both frameworks share the same `dispatchServerFunction` from `@payloadcms/ui`. Framework-specific setup (request init, cookie access) happens in each adapter's `handleServerFunctions` before dispatching to the shared handler.
+
+## File Structure
+
+```
+packages/
+├── payload/ # Core: types, Local API, AdminAdapter interface
+├── ui/ # Shared UI: all React components and views
+│ └── src/
+│ ├── views/
+│ │ ├── Dashboard/ # Shared dashboard components
+│ │ ├── List/
+│ │ │ ├── fetchListViewData.ts # Shared data function (serializable)
+│ │ │ ├── RenderListView.tsx # Builder (calls fetchListViewData + renders JSX)
+│ │ │ ├── index.tsx # DefaultListView client component
+│ │ │ └── ...
+│ │ ├── Document/
+│ │ │ ├── RenderDocument.tsx # Builder
+│ │ │ ├── DocumentView.tsx # Server wrapper
+│ │ │ └── ...
+│ │ ├── Login/ # Shared login form
+│ │ └── ...
+│ ├── templates/ # Default + Minimal shell templates
+│ ├── elements/ # Reusable UI elements
+│ ├── providers/ # RouterProvider, ServerFunctions, etc.
+│ └── utilities/ # handleServerFunctions registry
+│
+├── next/ # Next.js adapter (thin)
+│ └── src/
+│ ├── adapter/ # nextAdapter(), NextRouterProvider
+│ ├── views/ # Per-view thin wrappers (RSC → shared builder)
+│ │ ├── Dashboard/
+│ │ ├── List/ # RSC → renderListView() → rendered JSX
+│ │ ├── Document/ # RSC → renderDocument() → rendered JSX
+│ │ └── ...
+│ ├── layouts/Root/ # Next.js root layout
+│ └── utilities/ # initReq, handleServerFunctions
+│
+├── tanstack-start/ # TanStack adapter (thin)
+│ └── src/
+│ ├── adapter/ # TanStackRouterProvider
+│ ├── views/
+│ │ ├── Root/
+│ │ │ ├── getPageState.ts # Loader: calls fetchListViewData, etc.
+│ │ │ ├── types.ts # SerializablePageData (list, document, etc.)
+│ │ │ └── serverFunctions.ts # tanstackDocumentStateHandler
+│ │ ├── Dashboard/ # Reads pageData.dashboard → immediate render
+│ │ ├── List/ # Reads pageData.list → shell + async Table
+│ │ ├── Document/ # Reads pageData.document → immediate render
+│ │ ├── Account/ # Reads pageData.document → immediate render
+│ │ └── ...
+│ ├── layouts/Root/ # TanStack root layout
+│ └── utilities/ # initReq, handleServerFunctions
+```
+
+## The Rule
+
+For any view, ask: **where does this code belong?**
+
+| If the code… | It belongs in… |
+| ---------------------------------------- | ---------------------------- |
+| Renders UI (React components, JSX) | `@payloadcms/ui` |
+| Fetches data via Payload Local API | `@payloadcms/ui` (data fn) |
+| Returns serializable view data (no JSX) | `@payloadcms/ui` (data fn) |
+| Composes data + JSX into rendered view | `@payloadcms/ui` (builder) |
+| Uses `next/navigation` or `next/headers` | `@payloadcms/next` (adapter) |
+| Uses `@tanstack/react-router` | `@payloadcms/tanstack-start` |
+| Calls `redirect()` or `notFound()` | adapter (passed as callback) |
+| Defines collection/field/hook logic | `payload` (core) |
+
+## What's Different Between Adapters
+
+The only things that differ are **how** data reaches the shared component:
+
+| Concern | Next.js | TanStack Start |
+| ------------------- | ---------------------------------- | ---------------------------------------- |
+| Initial data fetch | RSC calls shared builder directly | Loader calls shared data function |
+| View rendering | Builder returns rendered JSX (RSC) | Loader provides data → component renders |
+| Non-serializable UI | Server-rendered in builder | Server function on demand after mount |
+| Server functions | `'use server'` actions | `createServerFn()` |
+| Request context | `next/headers` cookies/headers | `vinxi/http` getWebRequest |
+| Navigation | `next/navigation` hooks + Link | `@tanstack/react-router` hooks |
+| Redirect / NotFound | `next/navigation` throw helpers | `@tanstack/react-router` throws |
+| Route definitions | File-system routing (`app/`) | TanStack route tree |
+
+Everything else — the data functions, the components, the form state, the list rendering, the document editing, the dashboard widgets — is shared.
diff --git a/examples/custom-components/src/components/views/CustomDefaultRootView.tsx b/examples/custom-components/src/components/views/CustomDefaultRootView.tsx
index e2e975b165e..6684cbabb8e 100644
--- a/examples/custom-components/src/components/views/CustomDefaultRootView.tsx
+++ b/examples/custom-components/src/components/views/CustomDefaultRootView.tsx
@@ -1,18 +1,22 @@
import type { AdminViewProps } from 'payload'
import { DefaultTemplate } from '@payloadcms/next/templates'
+import { getNavPrefs } from '@payloadcms/next/utilities'
import { Gutter } from '@payloadcms/ui'
import React from 'react'
-export const CustomDefaultRootView: React.FC = ({
+export const CustomDefaultRootView: React.FC = async ({
initPageResult,
params,
searchParams,
}) => {
+ const navPreferences = await getNavPrefs(initPageResult.req)
+
return (
= {
+ next: {
+ description: 'Next.js App Router (recommended)',
+ title: 'Next.js',
+ value: 'next',
+ },
+ 'tanstack-start': {
+ description: 'TanStack Start (experimental)',
+ title: 'TanStack Start',
+ value: 'tanstack-start',
+ },
+}
+
+const validFrameworks = Object.keys(frameworkChoiceRecord) as FrameworkType[]
+
+export async function selectFramework(
+ args: CliArgs,
+ template?: ProjectTemplate,
+): Promise {
+ if (args['--framework']) {
+ const value = args['--framework'] as FrameworkType
+ if (!validFrameworks.includes(value)) {
+ throw new Error(`Invalid framework given. Valid values are: ${validFrameworks.join(', ')}`)
+ }
+ return value
+ }
+
+ if (template?.frameworkType) {
+ return template.frameworkType
+ }
+
+ const framework = await p.select<{ label: string; value: FrameworkType }[], FrameworkType>({
+ initialValue: 'next',
+ message: `Select a framework`,
+ options: Object.values(frameworkChoiceRecord).map((choice) => ({
+ hint: choice.description,
+ label: choice.title,
+ value: choice.value,
+ })),
+ })
+
+ if (p.isCancel(framework)) {
+ process.exit(0)
+ }
+
+ return framework
+}
diff --git a/packages/create-payload-app/src/main.ts b/packages/create-payload-app/src/main.ts
index 947abee37f0..2639b2cc211 100644
--- a/packages/create-payload-app/src/main.ts
+++ b/packages/create-payload-app/src/main.ts
@@ -17,6 +17,7 @@ import { manageEnvFiles } from './lib/manage-env-files.js'
import { parseProjectName } from './lib/parse-project-name.js'
import { parseTemplate } from './lib/parse-template.js'
import { selectDb } from './lib/select-db.js'
+import { selectFramework } from './lib/select-framework.js'
import { getValidTemplates, validateTemplate } from './lib/templates.js'
import { updatePayloadInProject } from './lib/update-payload-in-project.js'
import { getLatestPackageVersion } from './utils/getLatestPackageVersion.js'
@@ -41,6 +42,7 @@ export class Main {
'--db-accept-recommended': Boolean,
'--db-connection-string': String,
'--example': String,
+ '--framework': String,
'--help': Boolean,
'--local-template': String,
'--name': String,
@@ -69,6 +71,7 @@ export class Main {
// Aliases
'-d': '--db',
'-e': '--example',
+ '-f': '--framework',
'-h': '--help',
'-n': '--name',
'-t': '--template',
@@ -266,10 +269,12 @@ export class Main {
}
case 'starter': {
const dbDetails = await selectDb(this.args, projectName, template)
+ const frameworkType = await selectFramework(this.args, template)
await createProject({
cliArgs: this.args,
dbDetails,
+ frameworkType,
packageManager,
projectDir,
projectName,
diff --git a/packages/create-payload-app/src/types.ts b/packages/create-payload-app/src/types.ts
index 6e6f9cdd975..c2bb2afe0d9 100644
--- a/packages/create-payload-app/src/types.ts
+++ b/packages/create-payload-app/src/types.ts
@@ -12,6 +12,7 @@ export interface Args extends arg.Spec {
'--dry-run': BooleanConstructor
'--example': StringConstructor
+ '--framework': StringConstructor
'--help': BooleanConstructor
'--init-next': BooleanConstructor
'--local-example': StringConstructor
@@ -29,6 +30,7 @@ export interface Args extends arg.Spec {
// Aliases
'-e': string
+ '-f': string
'-h': string
'-n': string
'-t': string
@@ -64,10 +66,13 @@ export interface PluginTemplate extends Template {
interface Template {
dbType?: DbType
description?: string
+ frameworkType?: FrameworkType
name: string
type: ProjectTemplate['type']
}
+export type FrameworkType = 'next' | 'tanstack-start'
+
export type PackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn'
export type DbType = (typeof ALL_DATABASE_ADAPTERS)[number]
diff --git a/packages/next/src/adapter/RouterProvider.tsx b/packages/next/src/adapter/RouterProvider.tsx
new file mode 100644
index 00000000000..f782b57fd93
--- /dev/null
+++ b/packages/next/src/adapter/RouterProvider.tsx
@@ -0,0 +1,45 @@
+'use client'
+import type { RouterContextType, LinkProps as RouterLinkProps } from '@payloadcms/ui'
+
+import { RouterProvider as BaseRouterProvider } from '@payloadcms/ui'
+import NextLinkImport from 'next/link.js'
+import {
+ useParams as useNextParams,
+ usePathname as useNextPathname,
+ useRouter as useNextRouter,
+ useSearchParams as useNextSearchParams,
+} from 'next/navigation.js'
+import React from 'react'
+
+const NextLink = 'default' in NextLinkImport ? NextLinkImport.default : NextLinkImport
+
+const AdapterLink: React.FC = ({ children, ...rest }) => {
+ return {children}
+}
+
+export const NextRouterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const nextRouter = useNextRouter()
+ const pathname = useNextPathname()
+ const searchParams = useNextSearchParams()
+ const params = useNextParams()
+
+ const router: RouterContextType = React.useMemo(
+ () => ({
+ Link: AdapterLink,
+ params,
+ pathname,
+ router: {
+ back: nextRouter.back,
+ forward: nextRouter.forward,
+ prefetch: nextRouter.prefetch,
+ push: nextRouter.push,
+ refresh: nextRouter.refresh,
+ replace: nextRouter.replace,
+ },
+ searchParams,
+ }),
+ [nextRouter, pathname, searchParams, params],
+ )
+
+ return {children}
+}
diff --git a/packages/next/src/adapter/index.ts b/packages/next/src/adapter/index.ts
new file mode 100644
index 00000000000..2785434177a
--- /dev/null
+++ b/packages/next/src/adapter/index.ts
@@ -0,0 +1,47 @@
+import type { AdminAdapterResult, BaseAdminAdapter, CookieOptions } from 'payload'
+
+import { notFound, redirect } from 'next/navigation.js'
+import { createAdminAdapter } from 'payload'
+
+import { handleServerFunctions } from '../utilities/handleServerFunctions.js'
+import { initReq as nextInitReq } from '../utilities/initReq.js'
+import { NextRouterProvider } from './RouterProvider.js'
+
+export function nextAdapter(): AdminAdapterResult {
+ return {
+ name: 'next',
+ init: ({ payload }) => {
+ return createAdminAdapter({
+ name: 'next',
+ createRouteHandlers: () => {
+ // Route handlers in the Next.js adapter are set up via the file-system routing
+ // convention (app/api/[...slug]/route.ts) rather than being created dynamically.
+ // This is a no-op for the Next.js adapter.
+ return {}
+ },
+ deleteCookie: async (name: string) => {
+ const { cookies } = await import('next/headers.js')
+ const cookieStore = await cookies()
+ cookieStore.delete(name)
+ },
+ getCookie: async (name: string) => {
+ const { cookies } = await import('next/headers.js')
+ const cookieStore = await cookies()
+ return cookieStore.get(name)?.value
+ },
+ handleServerFunctions,
+ initReq: ({ config, importMap }) =>
+ nextInitReq({ configPromise: config, importMap, key: 'adapter' }),
+ notFound: () => notFound(),
+ payload,
+ redirect: (url: string) => redirect(url),
+ RouterProvider: NextRouterProvider,
+ setCookie: async (name: string, value: string, options?: CookieOptions) => {
+ const { cookies } = await import('next/headers.js')
+ const cookieStore = await cookies()
+ cookieStore.set(name, value, options)
+ },
+ } satisfies BaseAdminAdapter)
+ },
+ }
+}
diff --git a/packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx b/packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx
index fd890937121..2ba76893077 100644
--- a/packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx
+++ b/packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx
@@ -1,8 +1,7 @@
'use client'
import type { SanitizedConfig } from 'payload'
-import { Button } from '@payloadcms/ui'
-import { useParams, usePathname, useSearchParams } from 'next/navigation.js'
+import { Button, useParams, usePathname, useSearchParams } from '@payloadcms/ui'
import { formatAdminURL } from 'payload/shared'
import React from 'react'
diff --git a/packages/next/src/elements/Nav/index.client.tsx b/packages/next/src/elements/Nav/index.client.tsx
index d65fc56a8ec..4e08d3f1c6d 100644
--- a/packages/next/src/elements/Nav/index.client.tsx
+++ b/packages/next/src/elements/Nav/index.client.tsx
@@ -3,14 +3,8 @@
import type { groupNavItems } from '@payloadcms/ui/shared'
import type { NavPreferences } from 'payload'
-import { getTranslation } from '@payloadcms/translations'
-import { BrowseByFolderButton, Link, NavGroup, useConfig, useTranslation } from '@payloadcms/ui'
-import { EntityType } from '@payloadcms/ui/shared'
-import { usePathname } from 'next/navigation.js'
-import { formatAdminURL } from 'payload/shared'
-import React, { Fragment } from 'react'
-
-const baseClass = 'nav'
+import { AdminNavLinks } from '@payloadcms/ui'
+import React from 'react'
/**
* @internal
@@ -19,75 +13,5 @@ export const DefaultNavClient: React.FC<{
groups: ReturnType
navPreferences: NavPreferences
}> = ({ groups, navPreferences }) => {
- const pathname = usePathname()
-
- const {
- config: {
- admin: {
- routes: { browseByFolder: foldersRoute },
- },
- folders,
- routes: { admin: adminRoute },
- },
- } = useConfig()
-
- const { i18n } = useTranslation()
-
- const folderURL = formatAdminURL({
- adminRoute,
- path: foldersRoute,
- })
-
- const viewingRootFolderView = pathname.startsWith(folderURL)
-
- return (
-
- {folders && folders.browseByFolder && }
- {groups.map(({ entities, label }, key) => {
- return (
-
- {entities.map(({ slug, type, label }, i) => {
- let href: string
- let id: string
-
- if (type === EntityType.collection) {
- href = formatAdminURL({ adminRoute, path: `/collections/${slug}` })
- id = `nav-${slug}`
- }
-
- if (type === EntityType.global) {
- href = formatAdminURL({ adminRoute, path: `/globals/${slug}` })
- id = `nav-global-${slug}`
- }
-
- const isActive =
- pathname.startsWith(href) && ['/', undefined].includes(pathname[href.length])
-
- const Label = (
- <>
- {isActive && }
- {getTranslation(label, i18n)}
- >
- )
-
- // If the URL matches the link exactly
- if (pathname === href) {
- return (
-
- {Label}
-
- )
- }
-
- return (
-
- {Label}
-
- )
- })}
-
- )
- })}
-
- )
+ return
}
diff --git a/packages/next/src/elements/Nav/index.tsx b/packages/next/src/elements/Nav/index.tsx
index ae3f31fc155..c6e6731ae8f 100644
--- a/packages/next/src/elements/Nav/index.tsx
+++ b/packages/next/src/elements/Nav/index.tsx
@@ -1,212 +1,13 @@
-import type { EntityToGroup } from '@payloadcms/ui/shared'
-import type { PayloadRequest, ServerProps } from 'payload'
+import type { NavProps as UINavProps } from '@payloadcms/ui/elements/Nav'
-import { Logout } from '@payloadcms/ui'
-import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
-import { EntityType, groupNavItems } from '@payloadcms/ui/shared'
+import { DefaultNav as UIDefaultNav } from '@payloadcms/ui/elements/Nav'
import React from 'react'
-import { NavHamburger } from './NavHamburger/index.js'
-import { NavWrapper } from './NavWrapper/index.js'
-import { SettingsMenuButton } from './SettingsMenuButton/index.js'
-import './index.scss'
-
-const baseClass = 'nav'
-
import { getNavPrefs } from './getNavPrefs.js'
-import { DefaultNavClient } from './index.client.js'
-
-export type NavProps = {
- req?: PayloadRequest
-} & ServerProps
+export type NavProps = UINavProps
export const DefaultNav: React.FC = async (props) => {
- const {
- documentSubViewType,
- i18n,
- locale,
- params,
- payload,
- permissions,
- req,
- searchParams,
- user,
- viewType,
- visibleEntities,
- } = props
-
- if (!payload?.config) {
- return null
- }
-
- const {
- admin: {
- components: { afterNav, afterNavLinks, beforeNav, beforeNavLinks, logout, settingsMenu },
- },
- collections,
- globals,
- } = payload.config
-
- const groups = groupNavItems(
- [
- ...collections
- .filter(({ slug }) => visibleEntities.collections.includes(slug))
- .map(
- (collection) =>
- ({
- type: EntityType.collection,
- entity: collection,
- }) satisfies EntityToGroup,
- ),
- ...globals
- .filter(({ slug }) => visibleEntities.globals.includes(slug))
- .map(
- (global) =>
- ({
- type: EntityType.global,
- entity: global,
- }) satisfies EntityToGroup,
- ),
- ],
- permissions,
- i18n,
- )
-
- const navPreferences = await getNavPrefs(req)
-
- const LogoutComponent = RenderServerComponent({
- clientProps: {
- documentSubViewType,
- viewType,
- },
- Component: logout?.Button,
- Fallback: Logout,
- importMap: payload.importMap,
- serverProps: {
- i18n,
- locale,
- params,
- payload,
- permissions,
- searchParams,
- user,
- },
- })
-
- const RenderedSettingsMenu =
- settingsMenu && Array.isArray(settingsMenu)
- ? settingsMenu.map((item, index) =>
- RenderServerComponent({
- clientProps: {
- documentSubViewType,
- viewType,
- },
- Component: item,
- importMap: payload.importMap,
- key: `settings-menu-item-${index}`,
- serverProps: {
- i18n,
- locale,
- params,
- payload,
- permissions,
- searchParams,
- user,
- },
- }),
- )
- : []
-
- const RenderedBeforeNav = RenderServerComponent({
- clientProps: {
- documentSubViewType,
- viewType,
- },
- Component: beforeNav,
- importMap: payload.importMap,
- serverProps: {
- i18n,
- locale,
- params,
- payload,
- permissions,
- searchParams,
- user,
- },
- })
-
- const RenderedBeforeNavLinks = RenderServerComponent({
- clientProps: {
- documentSubViewType,
- viewType,
- },
- Component: beforeNavLinks,
- importMap: payload.importMap,
- serverProps: {
- i18n,
- locale,
- params,
- payload,
- permissions,
- searchParams,
- user,
- },
- })
-
- const RenderedAfterNavLinks = RenderServerComponent({
- clientProps: {
- documentSubViewType,
- viewType,
- },
- Component: afterNavLinks,
- importMap: payload.importMap,
- serverProps: {
- i18n,
- locale,
- params,
- payload,
- permissions,
- searchParams,
- user,
- },
- })
-
- const RenderedAfterNav = RenderServerComponent({
- clientProps: {
- documentSubViewType,
- viewType,
- },
- Component: afterNav,
- importMap: payload.importMap,
- serverProps: {
- i18n,
- locale,
- params,
- payload,
- permissions,
- searchParams,
- user,
- },
- })
+ const navPreferences = await getNavPrefs(props.req)
- return (
-
- {RenderedBeforeNav}
-
- {RenderedAfterNav}
-
-
- )
+ return
}
diff --git a/packages/next/src/exports/utilities.ts b/packages/next/src/exports/utilities.ts
index c198ff8eca9..a748c8719cc 100644
--- a/packages/next/src/exports/utilities.ts
+++ b/packages/next/src/exports/utilities.ts
@@ -1,5 +1,6 @@
// NOTICE: Server-only utilities, do not import anything client-side here.
+export { getNavPrefs } from '../elements/Nav/getNavPrefs.js'
export { getNextRequestI18n } from '../utilities/getNextRequestI18n.js'
export { getPayloadHMR } from '../utilities/getPayloadHMR.js'
diff --git a/packages/next/src/exports/views.ts b/packages/next/src/exports/views.ts
index 6977b766ed8..001e277e029 100644
--- a/packages/next/src/exports/views.ts
+++ b/packages/next/src/exports/views.ts
@@ -6,7 +6,11 @@ export {
type DashboardViewServerPropsOnly,
DefaultDashboard,
} from '../views/Dashboard/Default/index.js'
-export { DashboardView } from '../views/Dashboard/index.js'
+export {
+ DashboardView,
+ renderDashboardView,
+ type RenderDashboardViewArgs,
+} from '../views/Dashboard/index.js'
export { ListView, renderListView, type RenderListViewArgs } from '../views/List/index.js'
export { LoginView } from '../views/Login/index.js'
diff --git a/packages/next/src/index.js b/packages/next/src/index.js
index d9154a66aa9..744b9e9501c 100644
--- a/packages/next/src/index.js
+++ b/packages/next/src/index.js
@@ -1 +1,2 @@
+export { nextAdapter } from './adapter/index.js'
export { default as withPayload } from './withPayload/withPayload.js'
diff --git a/packages/next/src/layouts/Root/index.tsx b/packages/next/src/layouts/Root/index.tsx
index e9e1e6bd9b5..dfb5fc4699e 100644
--- a/packages/next/src/layouts/Root/index.tsx
+++ b/packages/next/src/layouts/Root/index.tsx
@@ -8,6 +8,7 @@ import { cookies as nextCookies } from 'next/headers.js'
import { applyLocaleFiltering } from 'payload/shared'
import React, { Suspense } from 'react'
+import { NextRouterProvider } from '../../adapter/RouterProvider.js'
import { getNavPrefs } from '../../elements/Nav/getNavPrefs.js'
import { getRequestTheme } from '../../utilities/getRequestTheme.js'
import { initReq } from '../../utilities/initReq.js'
@@ -130,40 +131,42 @@ const RootLayoutContent = async ({
-
-
- {Array.isArray(config.admin?.components?.providers) &&
- config.admin?.components?.providers.length > 0 ? (
-
- {children}
-
- ) : (
- children
- )}
-
+
+
+
+ {Array.isArray(config.admin?.components?.providers) &&
+ config.admin?.components?.providers.length > 0 ? (
+
+ {children}
+
+ ) : (
+ children
+ )}
+
+