Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/migration-guide/v4.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,60 @@ Several types and utilities that were re-exported from `@payloadcms/ui` and `@pa

Run `npx @payloadcms/codemod --transform migrate-aliased-exports` to migrate automatically. Renamed types are imported using an `as` alias (e.g. `import type { CollectionPreferences as ListPreferences } from 'payload'`) so existing usages keep compiling — drop the alias and rename usages manually if you want to fully commit to the new name.

### `next/navigation` and `next/link` replaced by framework-agnostic `RouterAdapter` hooks

`@payloadcms/ui` no longer depends on `next` directly. Custom admin components (field components, custom views, plugins) that previously imported router hooks or the `Link` component from `next/*` must now import the framework-agnostic equivalents from `@payloadcms/ui`. Behavior is identical when running on Next.js — the `NextRouterAdapter` shipped by `@payloadcms/next` wires `next/navigation` hooks and `next/link` into the same context — but ui code can now also run on non-Next adapters (e.g. TanStack Start) without modification.

**Hooks** — import from `@payloadcms/ui` instead of `next/navigation`:

| Old import | New import |
| --------------------------------------------------- | -------------------------------------------------- |
| `import { useRouter } from 'next/navigation'` | `import { useRouter } from '@payloadcms/ui'` |
| `import { usePathname } from 'next/navigation'` | `import { usePathname } from '@payloadcms/ui'` |
| `import { useSearchParams } from 'next/navigation'` | `import { useSearchParams } from '@payloadcms/ui'` |
| `import { useParams } from 'next/navigation'` | `import { useParams } from '@payloadcms/ui'` |

**`Link` component** — use `PayloadLink` from `@payloadcms/ui`:

```diff
- import Link from 'next/link'
+ import { PayloadLink as Link } from '@payloadcms/ui'
```

**Types** — `LinkProps` from `next/link` is replaced by `LinkAdapterProps` from `payload`:

```diff
- import type { LinkProps } from 'next/link'
+ import type { LinkAdapterProps } from 'payload'
```

`LinkAdapterProps` is a framework-neutral shape: `href: string`, optional `prefetch` / `replace` / `scroll` / `ref`, plus standard `AnchorHTMLAttributes<HTMLAnchorElement>` (minus `href`). The Next adapter forwards these to `next/link`.

**`RouterAdapterRouter` shape** — the object returned by `useRouter()` now has a stable, framework-agnostic surface:

```ts
type RouterAdapterRouter = {
back: () => void
push: (path: string, options?: { scroll?: boolean }) => void
refresh: () => void
replace: (path: string, options?: { scroll?: boolean }) => void
}
```

If you were relying on Next-specific extras like `prefetch()` on the router instance, switch to the `Link` component's `prefetch` prop instead.

**Custom apps embedding `RootProvider`** — `RootProvider` now requires a `RouterAdapter` prop. Apps using the default Next.js admin layout get this wired automatically. If you assemble `RootProvider` yourself, pass the framework's adapter component:

```diff
+ import { NextRouterAdapter } from '@payloadcms/next/elements/RouterAdapter'

<RootProvider
+ RouterAdapter={NextRouterAdapter}
config={clientConfig}
/* ... */
/>
```

### `title` and `setDocumentTitle` removed from `useDocumentInfo`

For performance reasons, the document title state has been split out of `DocumentInfoContext` into its own `DocumentTitleContext`. Access it through the `useDocumentTitle` hook, which exposes the same `title` and `setDocumentTitle` API. Components that subscribed to `useDocumentInfo` solely for the title will no longer re-render when unrelated document state changes.
Expand Down
74 changes: 74 additions & 0 deletions packages/next/src/adapters/router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use client'
import type { RouterAdapterContextValue } from '@payloadcms/ui'
import type { LinkAdapterProps } from 'payload'

import { RouterAdapterContext } 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'

type LinkComponent = React.FC<
{
children?: React.ReactNode
href: string
prefetch?: boolean
ref?: React.Ref<HTMLAnchorElement>
replace?: boolean
scroll?: boolean
} & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>
>

const NextLink: LinkComponent = ('default' in NextLinkImport
? NextLinkImport.default
: NextLinkImport) as unknown as LinkComponent

const NextLinkAdapter: React.FC<LinkAdapterProps> = ({
children,
href,
prefetch,
ref,
replace,
scroll,
...rest
}) => {
return (
<NextLink href={href} prefetch={prefetch} ref={ref} replace={replace} scroll={scroll} {...rest}>
{children}
</NextLink>
)
}

export const NextRouterAdapter: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const nextRouter = useNextRouter()
const pathname = useNextPathname()
const searchParams = useNextSearchParams()
const params = useNextParams()

const router = React.useMemo<RouterAdapterContextValue['router']>(
() => ({
back: nextRouter.back,
push: nextRouter.push,
refresh: nextRouter.refresh,
replace: nextRouter.replace,
}),
[nextRouter],
)

const value = React.useMemo<RouterAdapterContextValue>(
() => ({
Link: NextLinkAdapter,
params: params as Record<string, string | string[]>,
pathname,
router,
searchParams,
}),
[params, pathname, router, searchParams],
)

return <RouterAdapterContext value={value}>{children}</RouterAdapterContext>
}
2 changes: 2 additions & 0 deletions packages/next/src/layouts/Root/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { cookies as nextCookies } from 'next/headers.js'
import { applyLocaleFiltering } from 'payload/shared'
import React, { Suspense } from 'react'

import { NextRouterAdapter } from '../../adapters/router.js'
import { getNavPrefs } from '../../elements/Nav/getNavPrefs.js'
import { getRequestHighContrast } from '../../utilities/getRequestHighContrast.js'
import { getRequestTheme } from '../../utilities/getRequestTheme.js'
Expand Down Expand Up @@ -185,6 +186,7 @@ const RootLayoutContent = async ({
languageOptions={languageOptions}
locale={req.locale}
permissions={req.user ? permissions : null}
RouterAdapter={NextRouterAdapter}
serverFunction={serverFunction}
switchLanguageServerAction={switchLanguageServerAction}
theme={theme}
Expand Down
6 changes: 0 additions & 6 deletions packages/next/src/views/Dashboard/Default/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,6 @@ export type DashboardViewServerPropsOnly = {
lockDuration?: number
slug: string
}>
/**
* @deprecated
* This prop is deprecated and will be removed in the next major version.
* Components now import their own `Link` directly from `next/link`.
*/
Link?: React.ComponentType
navGroups?: ReturnType<typeof groupNavItems>
} & AdminViewServerPropsOnly

Expand Down
15 changes: 15 additions & 0 deletions packages/payload/src/admin/adapters/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type CookieStore = {
get: (name: string) => { name: string; value: string } | null | undefined
getAll?: () => Array<{ name: string; value: string }>
set?: (name: string, value: string, options?: CookieOptions) => void
}

export type CookieOptions = {
domain?: string
expires?: Date
httpOnly?: boolean
maxAge?: number
path?: string
sameSite?: 'lax' | 'none' | 'strict'
secure?: boolean
}
2 changes: 2 additions & 0 deletions packages/payload/src/admin/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type * from './cookies.js'
export type * from './router.js'
52 changes: 52 additions & 0 deletions packages/payload/src/admin/adapters/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type React from 'react'

/**
* Client-side router adapter to abstract away framework-specific routing implementations.
* This way plugins and server components can use server-only APIs without directly importing any framework-specific modules.
*
* @example
* ```tsx
* // Next.js router adapter (simplified):
* import { useRouter as useNextRouter, usePathname as useNextPathname } from 'next/navigation'
*
* const NextRouterAdapter: RouterAdapterComponent = ({ children }) => {
* const router = useNextRouter()
* const pathname = useNextPathname()
*
* return (
* <RouterAdapterContext value={{ router, pathname, ... }}>
* {children}
* </RouterAdapterContext>
* )
* }
* ```
*/
export type RouterAdapterComponent = React.ComponentType<{ children: React.ReactNode }>

export type RouterAdapterRouter = {
/**
* Navigate back to the previous page in the history stack.
*/
back: () => void
/**
* Navigate to a new path.
*/
push: (path: string, options?: { scroll?: boolean }) => void
/**
* Refresh the current route.
*/
refresh: () => void
/**
* Replace the current path with a new one.
*/
replace: (path: string, options?: { scroll?: boolean }) => void
}

export type LinkAdapterProps = {
children?: React.ReactNode
href: string
prefetch?: boolean
ref?: React.Ref<HTMLAnchorElement>
replace?: boolean
scroll?: boolean
} & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>
3 changes: 2 additions & 1 deletion packages/payload/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
verifyEmailLocal,
type Options as VerifyEmailOptions,
} from './auth/operations/local/verifyEmail.js'
export type { FieldState } from './admin/forms/Form.js'
export type * from './admin/adapters/index.js'
import type { InitOptions, SanitizedConfig } from './config/types.js'
import type { BaseDatabaseAdapter, PaginatedDistinctDocs, PaginatedDocs } from './database/types.js'
import type { InitializedEmailAdapter } from './email/types.js'
Expand Down Expand Up @@ -119,6 +119,7 @@ import {
updateGlobalLocal,
type Options as UpdateGlobalOptions,
} from './globals/operations/local/update.js'
export type { FieldState } from './admin/forms/Form.js'
export type * from './admin/types.js'
export { EntityType } from './admin/views/dashboard.js'
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

import type { OptionObject } from 'payload'

import { toast, useAuth, useConfig } from '@payloadcms/ui'
import { useRouter } from 'next/navigation.js'
import { toast, useAuth, useConfig, useRouter } from '@payloadcms/ui'
import { formatAdminURL } from 'payload/shared'
import React, { createContext } from 'react'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const LinkToDocClient: React.FC = () => {
textOverflow: 'ellipsis',
}}
>
<Link href={href} passHref {...{ rel: 'noopener noreferrer', target: '_blank' }}>
<Link href={href} rel="noopener noreferrer" target="_blank">
{href}
</Link>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
useConfig,
useLocale,
useModal,
useRouter,
useTranslation,
} from '@payloadcms/ui'
import { useRouter } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
import React, { useCallback, useMemo, useState } from 'react'

Expand Down
1 change: 0 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@
"payload": "workspace:*"
},
"peerDependencies": {
"next": ">=16.2.6 <17.0.0",
"payload": "workspace:*",
"react": "^19.0.1 || ^19.1.2 || ^19.2.1",
"react-dom": "^19.0.1 || ^19.1.2 || ^19.2.1"
Expand Down
1 change: 0 additions & 1 deletion packages/ui/src/elements/Autosave/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
'use client'
// TODO: abstract the `next/navigation` dependency out from this component
import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload'

import { dequal } from 'dequal/lite'
Expand Down
6 changes: 0 additions & 6 deletions packages/ui/src/elements/Button/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,6 @@ export type Props = {
icon?: ['chevron' | 'edit' | 'plus' | 'x'] | React.ReactNode
iconPosition?: 'left' | 'right'
id?: string
/**
* @deprecated
* This prop is deprecated and will be removed in the next major version.
* Components now import their own `Link` directly from `next/link`.
*/
Link?: React.ElementType
/**
* Shows a loading spinner and hides content. Disables interactions.
*/
Expand Down
6 changes: 0 additions & 6 deletions packages/ui/src/elements/Card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,6 @@ export type Props = {
buttonAriaLabel?: string
href?: string
id?: string
/**
* @deprecated
* This prop is deprecated and will be removed in the next major version.
* Components now import their own `Link` directly from `next/link`.
*/
Link?: React.ElementType
onClick?: () => void
title: string
titleAs?: React.ElementType
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
'use client'

import { useModal } from '@faceless-ui/modal'
import { usePathname } from 'next/navigation.js'
import { useEffect, useRef } from 'react'

import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { usePathname } from '../../providers/RouterAdapter/index.js'

export function CloseModalOnRouteChange() {
const { closeAllModals } = useModal()
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/elements/CopyLocaleData/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import { useRouter } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
import React, { useCallback } from 'react'
import { toast } from 'sonner'
Expand All @@ -13,6 +12,7 @@ import { useFormModified } from '../../forms/Form/context.js'
import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useRouter } from '../../providers/RouterAdapter/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/elements/DefaultListViewTabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
import type { ClientCollectionConfig, ClientConfig, ViewTypes } from 'payload'

import { getTranslation } from '@payloadcms/translations'
import { useRouter } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
import React from 'react'

import { usePreferences } from '../../providers/Preferences/index.js'
import { useRouter } from '../../providers/RouterAdapter/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import './index.scss'
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/elements/DeleteDocument/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { SanitizedCollectionConfig } from 'payload'

import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import { useRouter } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
import React, { Fragment, useCallback, useState } from 'react'
import { toast } from 'sonner'
Expand All @@ -15,6 +14,7 @@ import { useForm } from '../../forms/Form/context.js'
import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useDocumentTitle } from '../../providers/DocumentTitle/index.js'
import { useRouter } from '../../providers/RouterAdapter/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/elements/DeleteMany/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { ClientCollectionConfig, ViewTypes, Where } from 'payload'

import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import { useRouter, useSearchParams } from 'next/navigation.js'
import { formatAdminURL, mergeListSearchAndWhere } from 'payload/shared'
import * as qs from 'qs-esm'
import React from 'react'
Expand All @@ -14,6 +13,7 @@ import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useRouteCache } from '../../providers/RouteCache/index.js'
import { useRouter, useSearchParams } from '../../providers/RouterAdapter/index.js'
import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
Expand Down
Loading
Loading