actium
v1.0.0
Published
Professional, minimal, and infinitely composable type-safe server actions for Next.js
Downloads
8
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 approach with perfect TypeScript inference, built-in validation, and comprehensive error handling.
Features
- 🎯 Type-Safe - Perfect TypeScript inference across middleware chains
- 🔗 Composable - Actions as middleware with infinite nesting support
- ✅ Validation - Built-in Zod schema validation
- ⚡ Minimal - Zero dependencies (peer deps: React, Zod)
- 🎣 React Hook - Optimized
useActionhook with loading states - 🛡️ Error Handling - Structured error handling with field-level errors
- 📦 Tiny - < 5KB minified + gzipped
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 a Server Action
// app/actions.ts
"use server";
import { createAction } from "actium";
import { z } from "zod";
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. Use in Client Component
// app/components/CreatePostForm.tsx
"use client";
import { useAction } from "actium/react";
import { createPost } from "../actions";
export function CreatePostForm() {
const { execute, isPending, error, data } = useAction(createPost, {
onSuccess: (data) => {
console.log("Post created:", data.post);
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
execute({
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>}
</form>
);
}Core Concepts
Composable Actions
Actions compose like functions. Each action can use other actions as middleware, with perfect type inference:
const actionA = createAction().handler(() => ({ a: 1 }));
const actionB = createAction().handler(() => ({ b: 2 }));
const actionC = createAction()
.use(actionA)
.use(actionB)
.handler(({ ctx }) => ({
total: ctx.a + ctx.b // ✅ Fully typed!
}));Middleware Chain
Middleware executes in order and merges context:
const auth = createAction().handler(async () => {
const user = await getCurrentUser();
if (!user) throw new ActionError("NOT_AUTHORIZED");
return { user };
});
const myAction = createAction()
.use(auth)
.handler(({ ctx }) => {
// ctx.user is available and typed!
return { success: true };
});Examples
Authentication Middleware
// lib/middleware.ts
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 };
});
// Usage
export const deletePost = createAction()
.input(z.object({ postId: z.string() }))
.use(auth)
.handler(async ({ input, ctx }) => {
// ctx.user is available and typed
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 };
});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 ({ ctx }) => {
// Only admins can execute this
return { success: true };
});Error Handling
const { execute, error } = useAction(createPost, {
onError: (error) => {
if (error.code === "NOT_AUTHORIZED") {
router.push("/login");
} else if (error.code === "VALIDATION_ERROR") {
// Field errors available in error.fieldErrors
console.log(error.fieldErrors);
}
},
});
// Display field errors
{error?.fieldErrors?.title && (
<span className="error">{error.fieldErrors.title[0]}</span>
)}API Reference
createAction()
Creates a new action builder instance.
const action = createAction();.input(schema)
Defines input validation using Zod schema. The input type is automatically inferred.
const action = createAction()
.input(z.object({
email: z.string().email(),
age: z.number().min(18),
}))
.handler(async ({ input }) => {
// input is typed as { email: string; age: number }
return { success: true };
});.use(middleware | action)
Chains middleware or another action. Context types are automatically merged.
const middleware = createAction().handler(() => ({ userId: "123" }));
const action = createAction()
.use(middleware)
.handler(({ ctx }) => {
// ctx.userId is available and typed
return { success: true };
});.handler(fn)
Defines the action handler function. Returns a callable server action.
Parameters:
input: Validated input (if schema defined)ctx: Merged context from all middleware
const action = createAction()
.input(z.object({ name: z.string() }))
.handler(async ({ input, ctx }) => {
return { message: `Hello ${input.name}` };
});useAction(action, options)
React hook for executing server actions with state management.
Options:
type UseActionOptions<TData> = {
onSuccess?: (data: TData) => void | Promise<void>;
onError?: (error: ActionErrorResponse, retryCount?: number) => void | Promise<void>;
onSettled?: (data: TData | undefined, error: ActionErrorResponse | undefined) => void | Promise<void>;
retry?: number; // Number of retry attempts (default: 0)
retryDelay?: number; // Delay between retries in ms (default: 1000)
};Returns:
type UseActionReturn<TInput, TData> = {
execute: (input: TInput) => void; // Fire and forget
executeAsync: (input: TInput) => Promise<TData>; // Returns promise
reset: () => void; // Reset state
isPending: boolean; // Loading state
data: TData | undefined; // Success data
error: ActionErrorResponse | undefined; // Error object
isSuccess: boolean; // Success flag
isError: boolean; // Error flag
};ActionError
Custom error class for action errors.
import { ActionError } from "actium";
throw new ActionError(
"NOT_AUTHORIZED", // Error code
"Login required", // Error message
{ email: ["Invalid email"] } // Optional field errors
);Error Codes:
NOT_AUTHORIZED- User not authenticatedFORBIDDEN- User lacks permissionVALIDATION_ERROR- Input validation failedERROR- Generic error
Best Practices
1. Organize Actions by Feature
app/
├── actions/
│ ├── auth.ts # Authentication actions
│ ├── posts.ts # Post-related actions
│ ├── users.ts # User-related actions
│ └── middleware.ts # Shared middleware2. Create Reusable Middleware
// app/actions/middleware.ts
export const auth = createAction().handler(async () => {
const session = cookies().get("session");
if (!session) throw new ActionError("NOT_AUTHORIZED");
const user = await getUser(session.value);
return { user };
});
export const adminOnly = createAction()
.use(auth)
.handler(({ ctx }) => {
if (ctx.user.role !== "admin") {
throw new ActionError("FORBIDDEN", "Admin access required");
}
return {};
});3. Validate Input Strictly
const createPost = createAction()
.input(z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).max(5),
published: z.boolean().default(false),
}))
.handler(async ({ input }) => {
// input is strictly validated
});Troubleshooting
"use server" directive missing
Make sure your action files have "use server" at the top:
"use server";
import { createAction } from "actium";Type inference not working
Ensure you're using TypeScript 5+ with strict mode enabled in tsconfig.json.
Middleware context not available
Make sure middleware is added before the handler:
// ✅ Correct
const action = createAction()
.use(middleware)
.handler(({ ctx }) => { /* ctx available */ });FAQ
Can I use actium without React?
Yes! The core library works without React. Only import actium/react if you need the useAction hook.
Does actium work with the Pages Router?
actium is designed for the App Router with Server Actions. For the Pages Router, consider using API routes.
How do I handle file uploads?
Use FormData as input:
export const uploadFile = createAction()
.handler(async ({ input }) => {
const formData = input as FormData;
const file = formData.get("file") as File;
// Process file...
return { uploaded: true };
});Contributing
Contributions are welcome! Please follow these guidelines:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Development Setup
git clone https://github.com/ogabekyuldoshev/actium.git
cd actium
pnpm install
pnpm typecheck
pnpm lint
pnpm buildLicense
MIT License - see LICENSE file for details
Copyright (c) 2026 Ogabek Yuldoshev
Documentation • Issues • Discussions
Made with ❤️ for the Next.js community
