npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

actium

v2.0.0

Published

Professional, minimal, and infinitely composable type-safe server actions for Next.js

Downloads

167

Readme

actium

Type-safe, composable server actions for Next.js

npm version License: MIT TypeScript 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 hooksuseActionMutation and useActionQuery, 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 ActionError with 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 zod

Requirements: 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:

  1. Validate merged input schema (if any)
  2. Run middleware chain in .use() call order
  3. 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 needed

Multiple .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
  • invalidateActionCache triggers 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()                 // ActionErrorResponse

Types

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.ts

Reuse 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!

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/my-feature)
  3. Commit your changes
  4. 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 build

License

MIT — see LICENSE for details.

Copyright (c) 2026 Ogabek Yuldoshev


DocumentationIssuesDiscussions

Made with ❤️ for the Next.js community