actium
v2.0.0
Published
Professional, minimal, and infinitely composable type-safe server actions for Next.js
Maintainers
Readme
actium
Type-safe, composable server actions for Next.js
Overview
actium is a minimal library for building type-safe server actions in Next.js. It provides a composable, functional API with full TypeScript inference, built-in Zod validation, structured errors, and lightweight React hooks for mutations and reads — without a provider or heavy cache layer.
Features
- Type-safe — Full inference across middleware chains and action composition
- Composable — Actions as middleware with infinite nesting
- Validation — Built-in Zod schema validation with field-level errors
- Minimal React hooks —
useActionMutationanduseActionQuery, no provider required - Lightweight cache — Global in-memory cache with deduplication, stale time, and invalidation
- Flexible builder —
.input()and.use()can be called in any order - Error handling — Structured
ActionErrorwith codes; production-safe sanitization - Tiny — Zero runtime dependencies (peer deps: React, Zod)
Installation
pnpm add actium zod
# or
npm install actium zod
# or
yarn add actium zodRequirements: Next.js 14+, React 18+, Zod 3+, TypeScript 5+
Quick Start
1. Create server actions
// app/actions/posts.ts
"use server";
import { createAction, ActionError } from "actium";
import { z } from "zod";
export const getPosts = createAction().handler(async () => {
const posts = await db.post.findMany();
return { posts };
});
export const createPost = createAction()
.input(z.object({
title: z.string().min(1),
content: z.string(),
}))
.handler(async ({ input }) => {
const post = await db.post.create({ data: input });
return { post };
});2. Mutations in a client component
// app/components/CreatePostForm.tsx
"use client";
import { useActionMutation, invalidateActionCache } from "actium/react";
import { createPost } from "../actions/posts";
export function CreatePostForm() {
const { run, runAsync, isPending, error, data } = useActionMutation(createPost, {
onSuccess: () => {
invalidateActionCache({ key: ["posts"] });
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
run({
title: formData.get("title") as string,
content: formData.get("content") as string,
});
}}>
<input name="title" required />
<textarea name="content" required />
<button disabled={isPending}>
{isPending ? "Creating..." : "Create Post"}
</button>
{error && <p className="error">{error.message}</p>}
{data && <p>Created: {data.post.title}</p>}
</form>
);
}3. Reads in a client component
// app/components/PostList.tsx
"use client";
import { useActionQuery } from "actium/react";
import { getPosts } from "../actions/posts";
export function PostList() {
const { data, isPending, error, refetch } = useActionQuery(getPosts, {
key: ["posts"],
staleTime: 60_000,
});
if (isPending) return <p>Loading...</p>;
if (error) return <p>{error.message}</p>;
return (
<>
<button onClick={() => refetch()}>Refresh</button>
<ul>
{data?.posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</>
);
}Core Concepts
Runtime execution order
Builder call order does not control runtime order. Every action always runs:
- Validate merged input schema (if any)
- Run middleware chain in
.use()call order - Run handler
const action = createAction()
.use(auth) // runs 1st at runtime
.input(z.object({ id: z.string() })) // merged into validation
.use(logger) // runs 2nd at runtime
.handler(async ({ input, ctx }) => { /* ... */ });.input() and .use() can be interleaved freely — validation always happens before middleware.
Composable actions
Actions compose like middleware. Each action can use other actions, with full type inference:
const getSession = createAction().handler(async () => {
const user = await getCurrentUser();
if (!user) throw new ActionError("NOT_AUTHORIZED", "Login required");
return { user };
});
const deletePost = createAction()
.input(z.object({ postId: z.string() }))
.use(getSession)
.handler(async ({ input, ctx }) => {
const post = await db.post.findUnique({ where: { id: input.postId } });
if (post?.authorId !== ctx.user.id) {
throw new ActionError("FORBIDDEN", "Not your post");
}
await db.post.delete({ where: { id: input.postId } });
return { deleted: true };
});Middleware and context merging
Middleware returns context objects that are deep-merged into the handler's ctx:
const auth = createAction().handler(async () => ({
user: { id: "123", name: "John" },
}));
const withRole = createAction().handler(async () => ({
user: { role: "admin" },
}));
const action = createAction()
.use(auth)
.use(withRole)
.handler(({ ctx }) => {
// ctx.user = { id: "123", name: "John", role: "admin" }
return ctx.user;
});Plain middleware functions (not actions) also work:
const logger: Middleware<{ log: string[] }> = () => ({ log: ["started"] });
const action = createAction()
.use(logger)
.handler(({ ctx }) => ctx.log);Actions without input
When no .input() schema is defined, the action is callable without arguments:
const refresh = createAction().handler(async () => {
return { refreshedAt: Date.now() };
});
await refresh(); // no undefined neededMultiple .input() calls
Multiple .input() calls merge schemas (Zod object merge when both are objects):
const action = createAction()
.input(z.object({ id: z.string() }))
.input(z.object({ name: z.string() }))
.handler(async ({ input }) => input); // { id: string; name: string }React Hooks
Import hooks from actium/react. No provider or wrapper component is required.
useActionMutation
For writes: create, update, delete, form submissions.
const { run, runAsync, reset, status, isPending, isSuccess, isError, data, error } =
useActionMutation(createPost, {
resetOnRun: true,
onSuccess: (data, input) => { /* ... */ },
onError: (error, input) => { /* ... */ },
onSettled: (data, error, input) => { /* ... */ },
});| Option | Type | Default | Description |
|--------|------|---------|-------------|
| resetOnRun | boolean | false | Clear data and error when a new mutation starts |
| onSuccess | (data, input) => void | — | Called after a successful mutation |
| onError | (error, input) => void | — | Called after a failed mutation |
| onSettled | (data, error, input) => void | — | Called after success or failure |
| Return | Description |
|--------|-------------|
| run(input?) | Fire-and-forget (wrapped in startTransition) |
| runAsync(input?) | Returns Promise<TData>, throws ActionMutationError on failure |
| reset() | Reset to idle state |
| status | "idle" | "pending" | "success" | "error" |
| isPending | true while mutation or transition is pending |
| data | Last successful result |
| error | Last error (ActionErrorResponse) with preserved code |
Actions without input omit the argument:
const { run } = useActionMutation(refresh);
run();useActionQuery
For reads: lists, detail pages, server-fetched data.
const { data, error, status, isPending, isSuccess, isError, refetch } =
useActionQuery(getPost, {
key: ["post", postId],
input: { id: postId },
staleTime: 60_000,
enabled: Boolean(postId),
refetchOnMount: true,
});| Option | Type | Default | Description |
|--------|------|---------|-------------|
| key | readonly unknown[] | required | Cache key (like a query key) |
| input | TInput | — | Passed to the action when it expects input |
| staleTime | number | 0 | Ms before cached data is considered stale |
| enabled | boolean | true | Skip fetching when false |
| refetchOnMount | boolean | true | Refetch stale data when the hook mounts |
| Return | Description |
|--------|-------------|
| data | Cached or fetched data |
| error | Cached error with original code |
| status | "idle" | "pending" | "success" | "error" |
| isPending | true on initial load (no cached data yet) |
| refetch() | Force a fresh fetch |
Cache helpers
import {
invalidateActionCache,
removeActionCache,
getActionCacheData,
setActionCacheData,
} from "actium/react";
// Mark matching entries stale and trigger refetch in subscribed hooks
invalidateActionCache({ key: ["posts"] });
// Exact key match only
invalidateActionCache({ key: ["posts", 1], exact: true });
// Remove entries entirely
removeActionCache({ key: ["posts"] });
// Read or write cache imperatively (optimistic updates)
const posts = getActionCacheData<{ posts: Post[] }>(["posts"]);
setActionCacheData(["posts"], { posts: updatedPosts });Cache behavior:
- Global singleton on the client — shared across components, no provider needed
- In-flight request deduplication per cache key
- LRU eviction (default max 100 entries)
- SSR-safe: server renders use an ephemeral cache that never persists data
invalidateActionCachetriggers refetch even for entries in an error state
Typical mutation + query pattern
const { run, isPending } = useActionMutation(createPost, {
onSuccess: () => invalidateActionCache({ key: ["posts"] }),
});
const { data } = useActionQuery(getPosts, {
key: ["posts"],
staleTime: 60_000,
});Error Handling
ActionError
Throw structured errors from handlers and middleware:
import { ActionError } from "actium";
throw new ActionError("NOT_FOUND", "Post not found");
throw new ActionError("VALIDATION_ERROR", "Validation failed", {
email: ["Invalid email address"],
});Error codes:
| Code | Use case |
|------|----------|
| NOT_AUTHORIZED | User is not authenticated |
| FORBIDDEN | User lacks permission |
| NOT_FOUND | Resource not found |
| VALIDATION_ERROR | Input validation failed |
| ERROR | Generic error (development) |
| INTERNAL_SERVER_ERROR | Sanitized unknown error (production) |
Production sanitization
In production (NODE_ENV === "production"), unexpected errors thrown in handlers are converted to INTERNAL_SERVER_ERROR with a generic message. Explicit ActionError instances are never sanitized.
Client-side error handling
const { run, error } = useActionMutation(createPost, {
onError: (error) => {
if (error.code === "NOT_AUTHORIZED") {
router.push("/login");
}
if (error.code === "VALIDATION_ERROR") {
console.log(error.fieldErrors);
}
},
});
{error?.fieldErrors?.title?.map((msg) => (
<span key={msg} className="error">{msg}</span>
))}runAsync throws ActionMutationError which preserves the original error code:
try {
await runAsync({ title: "Hello" });
} catch (err) {
if (err instanceof ActionMutationError) {
console.log(err.actionError.code); // "NOT_FOUND", etc.
}
}API Reference
Core (actium)
createAction()
Returns a new ActionBuilder.
.input(schema)
Adds a Zod schema. Can be called multiple times; schemas are merged.
.use(middleware | action)
Chains middleware or another action. Context types are merged.
.handler(fn)
Returns a callable server action (ActiumAction).
- With input schema:
(input: TInput) => Promise<ActionResult<TOutput>> - Without input schema:
() => Promise<ActionResult<TOutput>>
Handler receives { input, ctx } or { ctx } depending on whether input is defined.
ActionError
new ActionError(code, message, fieldErrors?)
ActionError.fromError(unknown) // converts unknown errors
error.toJSON() // ActionErrorResponseTypes
import type {
ActionResult,
ActionErrorResponse,
ActionErrorCode,
ActiumAction,
ActionHandler,
Middleware,
Context,
} from "actium";React (actium/react)
| Export | Description |
|--------|-------------|
| useActionMutation | Hook for write operations |
| useActionQuery | Hook for read operations with cache |
| invalidateActionCache | Invalidate cache entries by key prefix |
| removeActionCache | Remove cache entries |
| getActionCacheData | Read cached data imperatively |
| setActionCacheData | Write cached data imperatively |
| getActionCache | Access the underlying ActionCache instance |
| ActionCache | Cache class (for advanced usage) |
| ActionMutationError | Error class thrown by runAsync |
| invokeAction | Low-level action invoker |
| useAction | Deprecated — use useActionMutation |
Examples
Authentication middleware
// app/actions/middleware.ts
"use server";
import { createAction, ActionError } from "actium";
import { cookies } from "next/headers";
export const auth = createAction().handler(async () => {
const session = cookies().get("session");
if (!session) {
throw new ActionError("NOT_AUTHORIZED", "Please login");
}
const user = await getUserFromSession(session.value);
return { user };
});Role-based authorization
export const requireRole = (role: string) =>
createAction()
.use(auth)
.handler(({ ctx }) => {
if (ctx.user.role !== role) {
throw new ActionError("FORBIDDEN", `Requires ${role} role`);
}
return {};
});
export const adminAction = createAction()
.use(requireRole("admin"))
.handler(async () => ({ success: true }));Query with input
// app/actions/posts.ts
export const getPost = createAction()
.input(z.object({ id: z.string() }))
.handler(async ({ input }) => {
const post = await db.post.findUnique({ where: { id: input.id } });
if (!post) throw new ActionError("NOT_FOUND", "Post not found");
return { post };
});const { data } = useActionQuery(getPost, {
key: ["post", id],
input: { id },
enabled: Boolean(id),
});Optimistic update
import { setActionCacheData, getActionCacheData, invalidateActionCache } from "actium/react";
const { run } = useActionMutation(toggleLike, {
onSuccess: (result) => {
setActionCacheData(["post", postId], result);
},
onError: () => {
invalidateActionCache({ key: ["post", postId] });
},
});Best Practices
Organize actions by feature
app/
├── actions/
│ ├── auth.ts
│ ├── posts.ts
│ └── middleware.tsReuse middleware as actions
export const auth = createAction().handler(async () => {
const user = await getCurrentUser();
if (!user) throw new ActionError("NOT_AUTHORIZED");
return { user };
});Invalidate after mutations
Always invalidate related query keys after writes so lists stay fresh:
onSuccess: () => invalidateActionCache({ key: ["posts"] })Use runAsync when you need control flow
const handleSubmit = async () => {
try {
const result = await runAsync(formData);
router.push(`/posts/${result.post.id}`);
} catch {
// error state is already set on the hook
}
};Troubleshooting
"use server" directive missing
Action files must start with "use server":
"use server";
import { createAction } from "actium";Type inference not working
Use TypeScript 5+ with "strict": true in tsconfig.json.
Query not refetching after mutation
Call invalidateActionCache with the same key prefix used in useActionQuery:
useActionQuery(getPosts, { key: ["posts"] });
invalidateActionCache({ key: ["posts"] });Stale mutation data while pending
Pass resetOnRun: true to clear previous data when a new mutation starts:
useActionMutation(createPost, { resetOnRun: true });FAQ
Can I use actium without React?
Yes. Import from actium for the core builder. Import actium/react only for hooks.
Do I need a provider?
No. The cache is a global client-side singleton. No ActionCacheProvider or setup required.
Is useAction still supported?
useAction is deprecated. It wraps useActionMutation and maps execute/executeAsync to run/runAsync. Migrate to useActionMutation.
Does actium work with the Pages Router?
actium targets the App Router with Server Actions. For the Pages Router, use API routes.
How do I handle file uploads?
Pass FormData through a Zod-validated or unvalidated action:
export const uploadFile = createAction()
.handler(async () => {
// receive FormData from client
return { uploaded: true };
});Contributing
Contributions are welcome!
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Commit your changes
- Push and open a Pull Request
Development
git clone https://github.com/ogabekyuldoshev/actium.git
cd actium
pnpm install
pnpm typecheck
pnpm test:run
pnpm buildLicense
MIT — see LICENSE for details.
Copyright (c) 2026 Ogabek Yuldoshev
Documentation • Issues • Discussions
Made with ❤️ for the Next.js community
