diff --git a/packages/next/src/utilities/handleServerFunctions.ts b/packages/next/src/utilities/handleServerFunctions.ts
index 3af73b1f71e..c44e831eb61 100644
--- a/packages/next/src/utilities/handleServerFunctions.ts
+++ b/packages/next/src/utilities/handleServerFunctions.ts
@@ -53,7 +53,13 @@ export const handleServerFunctions: ServerFunctionHandler = async (args) => {
req,
}
- const fn = extraServerFunctions?.[fnKey] || baseServerFunctions[fnKey]
+ // Lookup order:
+ // 1. integrator-supplied `serverFunctions` prop on (highest priority — back-compat)
+ // 2. `config.admin.serverFunctions` registry (populated by plugins via payload.config.ts)
+ // 3. built-in baseServerFunctions
+ const adminServerFunctions = req.payload.config.admin?.serverFunctions
+ const fn =
+ extraServerFunctions?.[fnKey] || adminServerFunctions?.[fnKey] || baseServerFunctions[fnKey]
if (!fn) {
throw new Error(`Unknown Server Function: ${fnKey}`)
diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts
index 1cf5e2b7aa0..498a6cfc6d5 100644
--- a/packages/payload/src/config/defaults.ts
+++ b/packages/payload/src/config/defaults.ts
@@ -32,6 +32,7 @@ export const defaults: Omit = {
reset: '/reset',
unauthorized: '/unauthorized',
},
+ serverFunctions: {},
theme: 'all',
},
auth: {
@@ -111,6 +112,9 @@ export const addDefaultsToConfig = (config: Config): Config => {
unauthorized: '/unauthorized',
...(config?.admin?.routes || {}),
},
+ serverFunctions: {
+ ...(config?.admin?.serverFunctions || {}),
+ },
}
config.bin = config.bin ?? []
diff --git a/packages/payload/src/config/sanitize.serverFunctions.spec.ts b/packages/payload/src/config/sanitize.serverFunctions.spec.ts
new file mode 100644
index 00000000000..99e713bbdb4
--- /dev/null
+++ b/packages/payload/src/config/sanitize.serverFunctions.spec.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest'
+
+import type { Config } from './types.js'
+
+import { sanitizeConfig } from './sanitize.js'
+
+/**
+ * Tests for the `admin.serverFunctions` registry added to the Config surface.
+ *
+ * The lookup-order behavior in `handleServerFunctions` is intentionally NOT tested
+ * here because that handler depends on the full Next.js / payload runtime stack
+ * (`initReq`, `getPayload`, request headers). Those code paths are exercised by
+ * the e2e suite in `test/server-functions/`. This file only covers the new
+ * sanitize-time validation and defaulting that lives in `sanitizeAdminConfig`.
+ */
+describe('admin.serverFunctions registry — sanitize', () => {
+ const minimalConfig = (): Config =>
+ // Cast: a fully-typed Config requires fields we don't care about for these tests.
+ ({
+ collections: [],
+ }) as unknown as Config
+
+ it('defaults `admin.serverFunctions` to an empty object when not provided', async () => {
+ const sanitized = await sanitizeConfig(minimalConfig())
+
+ expect(sanitized.admin?.serverFunctions).toBeDefined()
+ expect(sanitized.admin?.serverFunctions).toEqual({})
+ })
+
+ it('preserves user-supplied server functions on the sanitized config', async () => {
+ const handler = async () => 'ok'
+ const config = minimalConfig()
+ config.admin = {
+ serverFunctions: {
+ '@my-plugin/foo': handler,
+ },
+ }
+
+ const sanitized = await sanitizeConfig(config)
+
+ expect(sanitized.admin?.serverFunctions?.['@my-plugin/foo']).toBe(handler)
+ })
+
+ it('throws when a server function value is not a function', async () => {
+ const config = minimalConfig()
+ config.admin = {
+ // @ts-expect-error — intentionally invalid
+ serverFunctions: { 'bad/key': 'not a function' },
+ }
+
+ await expect(sanitizeConfig(config)).rejects.toThrow(/admin\.serverFunctions\["bad\/key"\]/)
+ })
+
+ it('throws when a server function key is an empty string', async () => {
+ const config = minimalConfig()
+ config.admin = {
+ serverFunctions: {
+ '': async () => 'ok',
+ },
+ }
+
+ await expect(sanitizeConfig(config)).rejects.toThrow(
+ /admin\.serverFunctions: keys must be non-empty strings/,
+ )
+ })
+})
diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts
index e65799c6e6d..d41d4f2b910 100644
--- a/packages/payload/src/config/sanitize.ts
+++ b/packages/payload/src/config/sanitize.ts
@@ -110,6 +110,29 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial
timezones: sanitizedConfig.admin!.timezones.supportedTimezones as Timezone[],
})
+ // Validate `admin.serverFunctions` registry. This is intentionally lightweight:
+ // - Keys must be non-empty strings (`Object.entries` already gives us strings, so
+ // we only need to reject the empty-string case which would silently break lookup).
+ // - Values must be functions; anything else means the plugin/integrator wired it up
+ // incorrectly and would otherwise fail with an opaque error at call time.
+ // We do not warn on cross-plugin key collisions here because plugins mutate the
+ // config one-by-one before `sanitizeConfig` runs — by the time we see the merged
+ // map the colliding entry is already overwritten. Plugins are expected to
+ // namespace their keys (e.g. `'@my-plugin/foo'`).
+ const serverFunctions = sanitizedConfig.admin?.serverFunctions
+ if (serverFunctions) {
+ for (const [key, value] of Object.entries(serverFunctions)) {
+ if (!key) {
+ throw new InvalidConfiguration(`admin.serverFunctions: keys must be non-empty strings.`)
+ }
+ if (typeof value !== 'function') {
+ throw new InvalidConfiguration(
+ `admin.serverFunctions["${key}"]: value must be a function, received ${typeof value}.`,
+ )
+ }
+ }
+ }
+
return sanitizedConfig as unknown as Partial
}
diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts
index 8bbfcbf8eb3..416fa47d1ad 100644
--- a/packages/payload/src/config/types.ts
+++ b/packages/payload/src/config/types.ts
@@ -16,6 +16,7 @@ import type React from 'react'
import type { default as sharp } from 'sharp'
import type { DeepRequired } from 'ts-essentials'
+import type { ServerFunction } from '../admin/functions/index.js'
import type { RichTextAdapterProvider } from '../admin/RichText.js'
import type {
CustomStatus,
@@ -1084,6 +1085,23 @@ export type Config = {
*/
unauthorized?: `/${string}`
}
+ /**
+ * Register custom server functions callable from the admin via
+ * `useServerFunctions().serverFunction({ name, args })`.
+ *
+ * This registry is intended for plugins that need to ship a server function
+ * without forcing every integrator to import and pass it into the
+ * `serverFunctions` prop on `` in `(payload)/layout.tsx`.
+ *
+ * Plugins should namespace their keys to avoid collisions
+ * (e.g. `'@my-plugin/render-foo'`).
+ *
+ * Lookup order in `handleServerFunctions`:
+ * 1. integrator-supplied `serverFunctions` prop on `` (highest priority — back-compat)
+ * 2. `config.admin.serverFunctions`
+ * 3. built-in payload server functions
+ */
+ serverFunctions?: Record
/**
* Suppresses React hydration mismatch warnings during the hydration of the root tag.
* Useful in scenarios where the server-rendered HTML might intentionally differ from the client-rendered DOM.