diff --git a/apps/content/docs/integrations/next.mdx b/apps/content/docs/integrations/next.mdx
index c1c3c94f1..25ffc0160 100644
--- a/apps/content/docs/integrations/next.mdx
+++ b/apps/content/docs/integrations/next.mdx
@@ -111,6 +111,27 @@ export const serverFunction = createServerFunction(procedure)
+### `useActionState`
+
+Server functions can be passed directly to React's [`useActionState`](https://react.dev/reference/react/useActionState). The previous state is ignored, `FormData` is parsed with [Bracket Notation](/docs/openapi/bracket-notation), and the form works before JavaScript loads.
+
+```tsx
+const [state, submit, isPending] = useActionState(serverFunction, null)
+const [error, data] = state ?? [null, undefined]
+
+return (
+
+)
+```
+
+:::warning
+Don't pass extra input with `.bind`, since the bound value is treated as the previous state. Use hidden inputs instead.
+:::
+
### `createServerFunctionable`
If you reuse the same options across multiple server functions, `createServerFunctionable` creates a preconfigured helper. The helper takes a procedure and returns a value that works as both a server function and the original [procedure](/docs/procedure) on the server.
diff --git a/packages/next/src/server-form-function.ts b/packages/next/src/server-form-function.ts
index 4949c80dd..5c5ee75e6 100644
--- a/packages/next/src/server-form-function.ts
+++ b/packages/next/src/server-form-function.ts
@@ -1,6 +1,6 @@
import type { AnySchema, Context, ErrorMap, Lazyable, Procedure, ProcedureClientOptions } from '@orpc/server'
import type { MaybeOptionalOptions } from '@orpc/shared'
-import { BracketNotationSerializer } from '@orpc/openapi'
+import { parseFormData } from '@orpc/openapi/helpers'
import { createProcedureClient } from '@orpc/server'
import { resolveMaybeOptionalOptions } from '@orpc/shared'
@@ -32,14 +32,12 @@ export function createServerFormFunction<
): ServerFormFunction {
const options = resolveMaybeOptionalOptions(rest)
const client = createProcedureClient(procedure, options)
- const serializer = new BracketNotationSerializer()
return async (form) => {
if (!(form instanceof FormData)) {
throw new TypeError('Server form function expects FormData.')
}
- const input = serializer.deserialize([...form])
- await client(input as any)
+ await client(parseFormData(form))
}
}
diff --git a/packages/next/src/server-function.test-d.ts b/packages/next/src/server-function.test-d.ts
index f07f82823..265437dbc 100644
--- a/packages/next/src/server-function.test-d.ts
+++ b/packages/next/src/server-function.test-d.ts
@@ -1,5 +1,6 @@
import type { ORPCErrorCode } from '@orpc/server'
import { os } from '@orpc/server'
+import { useActionState } from 'react'
import { z } from 'zod'
import { createServerFunction } from './server-function'
@@ -51,4 +52,31 @@ describe('createServerFunction', () => {
expectTypeOf(data).toEqualTypeOf<{ schema2: string }>()
}
})
+
+ it('plugs into useActionState', () => {
+ const fn = createServerFunction(procedure, { context: () => ({ auth: true }) })
+
+ const [state, dispatch] = useActionState(fn, null)
+ expectTypeOf(state).toEqualTypeOf> | null>()
+ dispatch({ schema1: 123 })
+ dispatch(new FormData())
+ // @ts-expect-error invalid input
+ dispatch({ schema1: 'invalid' })
+
+ const [initialized] = useActionState(fn, [null, { schema2: '123' }])
+ expectTypeOf(initialized).toEqualTypeOf>>()
+
+ // @ts-expect-error initial state must match the result
+ useActionState(fn, [null, { schema2: 123 }])
+ })
+
+ it('plugs into useActionState for procedures without input', () => {
+ const fn = createServerFunction(os.handler(() => 'output'))
+
+ const [state, dispatch] = useActionState(fn, null)
+ if (state && !state[0]) {
+ expectTypeOf(state[1]).toEqualTypeOf()
+ }
+ dispatch()
+ })
})
diff --git a/packages/next/src/server-function.test.ts b/packages/next/src/server-function.test.ts
index ec06a2a12..e1576faa5 100644
--- a/packages/next/src/server-function.test.ts
+++ b/packages/next/src/server-function.test.ts
@@ -45,6 +45,19 @@ describe('createServerFunction', () => {
await expect(serverFn({ input: 'ping' })).resolves.toEqual([new ORPCError('INTERNAL_SERVER_ERROR').toJSON(), undefined])
})
+ it('passes form data as-is when called with a single argument', async () => {
+ const client = vi.fn().mockResolvedValue({ output: 'pong' })
+ createProcedureClientSpy.mockReturnValueOnce(client)
+
+ const serverFn = createServerFunction(procedure)
+
+ const form = new FormData()
+ form.append('name', 'alice')
+
+ await serverFn(form)
+ expect(client).toHaveBeenCalledWith(form)
+ })
+
it.each([
[() => next.redirect('/foo')],
[() => next.forbidden()],
diff --git a/packages/next/src/server-function.test.tsx b/packages/next/src/server-function.test.tsx
new file mode 100644
index 000000000..92cd3fcd4
--- /dev/null
+++ b/packages/next/src/server-function.test.tsx
@@ -0,0 +1,32 @@
+import { os } from '@orpc/server'
+import { act, renderHook } from '@testing-library/react'
+import { startTransition, useActionState } from 'react'
+import { z } from 'zod'
+import { createServerFunction } from './server-function'
+
+describe('createServerFunction with useActionState', () => {
+ const fn = createServerFunction(
+ os
+ .input(z.object({ name: z.string().min(3) }))
+ .handler(({ input }) => `Hello ${input.name}!`),
+ )
+
+ it('works as a useActionState action', async () => {
+ const { result } = renderHook(() => useActionState(fn, null))
+
+ const form = new FormData()
+ form.append('name', 'Alice')
+
+ await act(async () => {
+ startTransition(() => result.current[1](form))
+ })
+
+ expect(result.current[0]).toEqual([null, 'Hello Alice!'])
+
+ await act(async () => {
+ startTransition(() => result.current[1]({ name: 'Bob' }))
+ })
+
+ expect(result.current[0]).toEqual([null, 'Hello Bob!'])
+ })
+})
diff --git a/packages/next/src/server-function.ts b/packages/next/src/server-function.ts
index 37a89ea42..d7adef813 100644
--- a/packages/next/src/server-function.ts
+++ b/packages/next/src/server-function.ts
@@ -1,5 +1,6 @@
import type { AnyORPCErrorJSON, AnySchema, Context, ErrorMap, InferSchemaInput, InferSchemaOutput, Lazyable, ORPCError, ORPCErrorCode, ORPCErrorFromErrorMap, ORPCErrorJSON, Procedure, ProcedureClientOptions, ThrowableError } from '@orpc/server'
import type { MaybeOptionalOptions } from '@orpc/shared'
+import { parseFormData } from '@orpc/openapi/helpers'
import { createProcedureClient, toORPCError } from '@orpc/server'
import { resolveMaybeOptionalOptions } from '@orpc/shared'
import { unstable_rethrow } from 'next/navigation'
@@ -22,6 +23,11 @@ export type ServerFunctionResult = [error: null, data: TOutput]
export interface ServerFunction {
(...rest: ServerFunctionRest): Promise>
+ /**
+ * The [`useActionState`](https://react.dev/reference/react/useActionState) shape, picked when called with two arguments.
+ * The previous state is ignored, and `FormData` input is deserialized using bracket notation.
+ */
+ (prevState: unknown, input: TInput | FormData): Promise>
}
export type ProcedureServerFunction<
@@ -43,6 +49,9 @@ export type ProcedureServerFunction<
* **Note**: Instead of throwing, the returned function resolves an `[error, data]` tuple
* with errors serialized as plain JSON (`ORPCErrorJSON`).
*
+ * **Note**: When called with two arguments, the returned function follows the `useActionState`
+ * shape `(prevState, input | FormData)`, so it can be passed to `useActionState` directly.
+ *
* @see {@link https://orpc.dev/docs/integrations/next#server-functions | Next.js Integration - Server Functions}
*/
export function createServerFunction<
@@ -70,7 +79,13 @@ export function createServerFunction<
const options = resolveMaybeOptionalOptions(rest)
const client = createProcedureClient(procedure, options)
- return async (...[input]) => {
+ return async (...args: unknown[]) => {
+ let input = args[0]
+
+ if (args.length > 1) {
+ input = args[1] instanceof FormData ? parseFormData(args[1]) : args[1]
+ }
+
try {
return [null, await client(input as any)]
}