Actions
Actions handle form submissions and data mutations. They run on the server when a non-GET request (POST, PUT, DELETE, etc.) is sent to a route.
Not sure which approach to use? See the Data Loading overview for a comparison of all three approaches (plain export, createAction, defineRoute).
Import
import {
createAction,
action,
typedAction,
jsonAction,
parseRequestBody,
formDataToObject,
parseFormData,
coerceValue,
validateRequired,
combineValidators,
redirect,
throwRedirect,
json,
data,
error,
type ActionOptions,
type TypedActionOptions,
type ActionResult,
type ValidationResult,
type TypedActionArgs,
type ActionBody,
} from '@ereo/data'createAction
Creates a type-safe action function. Accepts either a plain function (shorthand) or an options object (with validation, error handling, and automatic FormData parsing).
Signature
// Shorthand — pass a function directly
function createAction<T, P = Record<string, string>>(
fn: (args: ActionArgs<P>) => T | Promise<T>
): ActionFunction<T, P>
// Full options — with validation, auto-parsed FormData, error handling
function createAction<T, P = Record<string, string>>(
options: ActionOptions<T, P>
): ActionFunction<T, P>ActionOptions
interface ActionOptions<T, P> {
handler: (args: ActionArgs<P> & { formData: FormData }) => T | Promise<T>;
validate?: (formData: FormData) => ValidationResult | Promise<ValidationResult>;
onError?: (error: Error, args: ActionArgs<P>) => T | Response | Promise<T | Response>;
}ActionArgs
interface ActionArgs<P = RouteParams> {
request: Request; // The incoming Request object
params: P; // URL parameters from dynamic segments
context: AppContext; // App context (cookies, headers, session, etc.)
}Examples
Shorthand (Plain Function)
The simplest way to use createAction — just pass an async function. You handle FormData parsing yourself:
export const action = createAction(async ({ request }) => {
const formData = await request.formData()
const title = formData.get('title') as string
const content = formData.get('content') as string
const post = await db.posts.create({ title, content })
return redirect(`/posts/${post.id}`)
})This is equivalent to a plain function export:
// These two are equivalent:
export const action = createAction(async (args) => { ... })
export async function action(args) { ... }Options Object (With Validation)
Use the options object when you want automatic FormData parsing and a validation step. The handler receives pre-parsed formData, and validation runs before the handler:
export const action = createAction({
handler: async ({ formData }) => {
// formData is already parsed — no need to call request.formData()
await db.posts.create(Object.fromEntries(formData))
return redirect('/posts')
},
validate: (formData) => {
const errors: Record<string, string[]> = {}
if (!formData.get('title')) {
errors.title = ['Title is required']
}
if (!formData.get('content')) {
errors.content = ['Content is required']
}
return { success: Object.keys(errors).length === 0, errors }
},
})When validation fails, the action returns { success: false, errors: { ... } } without calling the handler.
Returning Errors
export const action = createAction(async ({ request }) => {
const formData = await request.formData()
const email = formData.get('email') as string
if (!isValidEmail(email)) {
return {
error: 'Invalid email address',
values: { email }
}
}
await subscribe(email)
return { success: true }
})Plain Function Export (Alternative)
You can also export a plain async function as action. No imports needed:
import type { ActionArgs } from '@ereo/core'
export async function action({ request, params }: ActionArgs<{ id: string }>) {
const formData = await request.formData()
const intent = formData.get('intent')
if (intent === 'delete') {
await db.posts.delete(params.id)
return redirect('/posts')
}
return { success: true }
}This works because the EreoJS server calls whatever function is exported as action. The createAction helpers add features like validation and automatic FormData parsing on top.
action (Shorthand Helper)
A convenience function that wraps a handler in createAction. The handler receives formData as part of its arguments (no need to parse it yourself):
import { action } from '@ereo/data'
export const myAction = action(async ({ formData, params }) => {
const title = formData.get('title') as string
await db.posts.update(params.id, { title })
return { success: true }
})Note: Avoid naming conflicts by using a different variable name (e.g.,
myAction) when importing theactionhelper, since route files conventionally exportaction.
typedAction
Creates an action with a typed request body. Automatically handles both JSON and FormData content types with type coercion.
Signature
function typedAction<TBody, TResult, P>(
options: TypedActionOptions<TBody, TResult, P>
): ActionFunction<ActionResult<TResult>, P>TypedActionOptions
interface TypedActionOptions<TBody, TResult, P> {
handler: (args: TypedActionArgs<TBody, P>) => TResult | Promise<TResult>;
validate?: (body: TBody) => ValidationResult | Promise<ValidationResult>;
transform?: (raw: unknown) => TBody;
schema?: { parse(data: unknown): TBody; safeParse?(...): {...} };
onError?: (error: Error, args: ActionArgs<P>) => TResult | Response | Promise<TResult | Response>;
}Examples
With Inline Type
interface CreatePostBody {
title: string
content: string
tags: string[]
}
export const action = typedAction<CreatePostBody, { id: string }>({
handler: async ({ body }) => {
// body is typed as CreatePostBody
const post = await db.posts.create(body)
return { id: post.id }
},
validate: (body) => {
if (!body.title || body.title.length < 3) {
return { success: false, errors: { title: ['Title must be at least 3 characters'] } }
}
return { success: true }
},
})With Schema (Zod)
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).default([]),
published: z.boolean().default(false),
})
export const action = typedAction({
schema: CreatePostSchema,
handler: async ({ body }) => {
// body is inferred from schema. Validation is automatic.
return db.posts.create({ data: body })
},
})jsonAction
Creates an action that only accepts JSON payloads. Useful for strict API endpoints.
Signature
function jsonAction<TBody, TResult = TBody, P = RouteParams>(
options: Omit<TypedActionOptions<TBody, TResult, P>, 'transform'> & { strict?: boolean }
): ActionFunction<ActionResult<TResult>, P>Note:
jsonActiondoes not support thetransformoption (unliketypedAction). If you need a custom transform step, usetypedActioninstead.
Example
export const action = jsonAction<{ ids: number[] }>({
strict: true, // Returns 415 if Content-Type is not application/json
handler: async ({ body }) => {
await db.posts.deleteMany({ where: { id: { in: body.ids } } })
return { deleted: body.ids.length }
},
})Response Helpers
redirect
Creates a redirect response.
function redirect(url: string, statusOrInit?: number | ResponseInit): Responsereturn redirect('/posts') // 302 redirect (default)
return redirect('/posts', 301) // 301 permanent redirect
return redirect('/posts', 303) // 303 redirect (after POST)throwRedirect
Throws a redirect response, immediately stopping execution. Useful inside loaders where you want to bail out early:
function throwRedirect(url: string, statusOrInit?: number | ResponseInit): never// This throws — execution stops immediately
throwRedirect('/login')
// Code after throwRedirect is never reachedjson
Creates a JSON response.
function json<T>(data: T, init?: ResponseInit): Responsereturn json({ success: true })
return json({ error: 'Not found' }, { status: 404 })data
Creates an XSS-safe JSON response. Escapes <, >, &, and ' characters to prevent script injection when embedding data in HTML:
function data<T>(value: T, init?: ResponseInit): Response// Use this when embedding data in HTML/script tags
return data({ post })error
Creates an error response.
function error(message: string, status?: number): Responsethrow error('Not found', 404)
throw error('Unauthorized', 401)Utility Functions
parseRequestBody
Parses the request body based on content type. Automatically handles JSON, FormData, and text.
function parseRequestBody(request: Request): Promise<{
body: unknown;
formData?: FormData;
contentType: 'json' | 'form' | 'text' | 'unknown';
}>export const action = createAction(async ({ request }) => {
const { body, contentType } = await parseRequestBody(request)
// body is parsed based on Content-Type header
})formDataToObject
Converts FormData to a typed object with automatic type coercion and support for nested objects/arrays.
function formDataToObject<T = Record<string, unknown>>(
formData: FormData,
options?: { coerce?: boolean }
): TConventions supported:
field[]or multiple same-name fields -> arrayfield.nested-> nested objectfield[0],field[1]-> indexed array- Automatic type coercion:
"true"->true,"42"->42, ISO dates ->Date
const formData = new FormData()
formData.append('user.name', 'Alice')
formData.append('user.email', 'alice@example.com')
formData.append('tags[]', 'typescript')
formData.append('tags[]', 'react')
formData.append('active', 'true')
const data = formDataToObject(formData)
// {
// user: { name: 'Alice', email: 'alice@example.com' },
// tags: ['typescript', 'react'],
// active: true // coerced from string
// }parseFormData
Simple FormData to typed object conversion (flat only, no nested objects).
function parseFormData<T extends Record<string, unknown>>(formData: FormData): Partial<T>coerceValue
Coerces a string value to the appropriate JavaScript type.
function coerceValue(value: string): unknownSupported conversions: 'true'/'false' -> boolean, 'null' -> null, numeric strings -> number, ISO dates -> Date, JSON strings -> parsed object.
validateRequired
Validates that required fields are present in FormData.
function validateRequired(formData: FormData, fields: string[]): ValidationResultconst result = validateRequired(formData, ['email', 'password'])
// { success: false, errors: { password: ['password is required'] } }combineValidators
Combines multiple validation functions into one. Collects all errors from all validators.
function combineValidators(
...validators: Array<(formData: FormData) => ValidationResult | Promise<ValidationResult>>
): (formData: FormData) => Promise<ValidationResult>const validateSignup = combineValidators(
(fd) => validateRequired(fd, ['email', 'password', 'name']),
validateEmail,
validatePassword,
)
export const action = createAction({
validate: validateSignup,
handler: async ({ formData }) => {
// All validations passed
},
})Handling Multiple Actions
Use an intent field to handle multiple actions in one route:
export const action = createAction(async ({ request }) => {
const formData = await request.formData()
const intent = formData.get('intent')
switch (intent) {
case 'create':
return handleCreate(formData)
case 'update':
return handleUpdate(formData)
case 'delete':
return handleDelete(formData)
default:
throw error('Unknown action', 400)
}
})In the component:
<Form method="post">
<input name="title" />
<button name="intent" value="create">Create</button>
<button name="intent" value="update">Update</button>
</Form>
<Form method="post">
<button name="intent" value="delete">Delete</button>
</Form>Using Action Data
Access action results in components:
import { useActionData } from '@ereo/client'
export default function NewPost() {
const actionData = useActionData()
return (
<Form method="post">
<input
name="title"
defaultValue={actionData?.values?.title}
/>
{actionData?.error && <p className="error">{actionData.error}</p>}
{actionData?.success && <p className="success">Created!</p>}
<button type="submit">Create</button>
</Form>
)
}Optimistic Updates
Combine actions with optimistic UI using useFetcher:
import { useFetcher } from '@ereo/client'
function LikeButton({ postId, initialLikes }) {
const fetcher = useFetcher()
// Optimistic: show +1 immediately while submitting
const likes = fetcher.formData
? initialLikes + 1
: initialLikes
return (
<fetcher.Form method="post" action="/api/like">
<input type="hidden" name="postId" value={postId} />
<button disabled={fetcher.state !== 'idle'}>
{likes} Likes
</button>
</fetcher.Form>
)
}Best Practices
- Start with the shorthand — Use
createAction(fn)until you need validation - Validate input — Use the options object form for server-side validation
- Return meaningful data — Include success/error status and relevant data
- Use redirects after mutations — Prevent form resubmission with POST-redirect-GET
- Handle errors gracefully — Return error objects for the UI, throw for error boundaries
- Use intent for multiple actions — Keep related actions in one route file
- Type your actions — Use
typedActionor generics for type safety
Related
- Data Loading Concepts — Overview of all approaches
- Loaders — Data fetching
- defineRoute Builder — Builder pattern
- Forms Guide — Form handling patterns
- useActionData — Client hook
- Form Component — Enhanced form component