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.
- 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)
pnpm add actium zod
# or
npm install actium zod
# or
yarn add actium zodRequirements: Next.js 14+, React 18+, Zod 3+, TypeScript 5+
// 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 };
});// 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>
);
}// 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>
</>
);
}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.
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 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);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 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 }Import hooks from actium/react. No provider or wrapper component is required.
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();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 |
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
const { run, isPending } = useActionMutation(createPost, {
onSuccess: () => invalidateActionCache({ key: ["posts"] }),
});
const { data } = useActionQuery(getPosts, {
key: ["posts"],
staleTime: 60_000,
});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) |
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.
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.
}
}Returns a new ActionBuilder.
Adds a Zod schema. Can be called multiple times; schemas are merged.
Chains middleware or another action. Context types are merged.
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.
new ActionError(code, message, fieldErrors?)
ActionError.fromError(unknown) // converts unknown errors
error.toJSON() // ActionErrorResponseimport type {
ActionResult,
ActionErrorResponse,
ActionErrorCode,
ActiumAction,
ActionHandler,
Middleware,
Context,
} from "actium";| 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 |
// 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 };
});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 }));// 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),
});import { setActionCacheData, getActionCacheData, invalidateActionCache } from "actium/react";
const { run } = useActionMutation(toggleLike, {
onSuccess: (result) => {
setActionCacheData(["post", postId], result);
},
onError: () => {
invalidateActionCache({ key: ["post", postId] });
},
});app/
├── actions/
│ ├── auth.ts
│ ├── posts.ts
│ └── middleware.ts
export const auth = createAction().handler(async () => {
const user = await getCurrentUser();
if (!user) throw new ActionError("NOT_AUTHORIZED");
return { user };
});Always invalidate related query keys after writes so lists stay fresh:
onSuccess: () => invalidateActionCache({ key: ["posts"] })const handleSubmit = async () => {
try {
const result = await runAsync(formData);
router.push(`/posts/${result.post.id}`);
} catch {
// error state is already set on the hook
}
};"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 });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 };
});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
git clone https://github.com/ogabekyuldoshev/actium.git
cd actium
pnpm install
pnpm typecheck
pnpm test:run
pnpm buildMIT — see LICENSE for details.
Copyright (c) 2026 Ogabek Yuldoshev
Documentation • Issues • Discussions
Made with ❤️ for the Next.js community