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

marci-admin

v0.0.5

Published

Downloads

307

Readme

marci-admin

Spin up a headless CMS / admin panel in minutes on top of @den59k/marci.

marci-admin turns a set of route files into a fully functional admin panel: list views with pagination, item pages, create/update forms, and bulk delete — all wired to your own data layer.

It is database-agnostic: there is no built-in ORM and no assumptions about your storage. You provide async handlers, and the panel calls them. That's why the page API is expressed as a small set of hooks (.data, .item, .createForm, …) rather than a schema bound to a particular database.

It is built on @den59k/marci, a Bun-first framework with a Fastify-like API. Because of that you can port the panel to Fastify if you ever need to, but on marci (running on Bun) it's faster.

Note: @den59k/marci is currently a working name and may change.

Features

  • Headless — ships the backend and the page-definition API; your data and UI logic stay yours.
  • Database-agnostic — works with any ORM or raw driver; you just return data from async functions.
  • Pluggable auth — define your own login fields and token logic via registerAuthMethod.
  • File-based pages — drop a file into a routes folder and register it; one file = one admin page.
  • Typed page builder — a fluent, fully typed API for tables, forms, and actions.
  • Fastify-compatible — runs fastest on @den59k/marci (Bun), but the surface is portable to Fastify.

Installation

bun add marci-admin

Quick start

Create a panel, load your page modules from a folder, attach an auth method, and register it on your app.

import { createAdminPanel, HTTPError } from "marci-admin"
import { Glob } from "bun"
import { join } from "node:path"

const adminPanel = createAdminPanel()

// Load every page module from ./routes-admin
const adminPages = new Glob("**/*.ts")
for await (const routeFile of adminPages.scan({ cwd: join(__dirname, "routes-admin") })) {
  const module = await import(join(__dirname, "routes-admin", routeFile))
  if (!module.default) {
    continue
  }
  adminPanel.register(module.default, routeFile.slice(0, routeFile.indexOf(".")))
}

// Mount onto your marci (or Fastify-compatible) app
app.register(adminPanel)

Authentication

Register an auth method by describing the login form fields and providing the login / per-request logic. Throw an HTTPError to reject a request.

import { createAdminPanel, HTTPError } from "marci-admin"

const accounts: Record<string, string> = {
  root: "123123"
}

adminPanel.registerAuthMethod({
  fields: {
    login: { type: "string", label: "Login" },
    password: { type: "string", label: "Password", hidden: true }
  },
  async onLogin(data) {
    if (!accounts[data.login] || accounts[data.login] !== data.password) {
      throw new HTTPError("Wrong login or password", 403)
    }
    return generateToken({ login: data.login })
  },
  async onRequest(token) {
    const data = verifyToken(token)
    if (!data) {
      throw new HTTPError("Wrong token")
    }
  }
})
  • fields — the fields shown on the login screen (same field options as form schemas; see below).
  • onLogin(data) — receives the submitted fields and returns a token string on success.
  • onRequest(token) — runs on each authenticated request; throw to reject.

(generateToken / verifyToken above are your own token helpers — use whatever you like, e.g. JWT.)

Defining a page

Each page is a module with a default export: a function that receives the AdminPanel instance and the page path, and returns a configured page. Forms are described with compact-json-schema.

import { type AdminPanel } from "marci-admin"
import { schema } from "compact-json-schema"
import { db } from "../plugins/db"

const userSchema = schema({
  name: { type: "string", width: 0.5, label: "First name" },
  surname: { type: "string", width: 0.5, label: "Last name" },
  description: { type: "string", multiline: true, label: "Bio" }
})

export default (admin: AdminPanel, path: string) =>
  admin.createPage({ title: "Users", path })
    .data(async ({ take, skip }) => {
      return await db.users.findMany({ select: { id: true, name: true }, take, skip })
    })
    .primaryKey("id", "number")
    .item(async (id) => {
      return await db.users.findFirst({ where: { id } })
    })
    .table({
      id: { title: "ID", width: 60 },
      name: { title: "Name" },
      _actions: { title: "Test", map: item => item.id }
    })
    .createForm(userSchema, async (data) => {
      await db.users.create(data)
    })
    .updateForm(userSchema, async (itemId, data) => {
      await db.users.update({ id: itemId }, data)
    })
    .onDelete(async (ids) => {
      await db.users.delete({ id: { in: ids } })
    })

Page API reference

The page builder is a fluent chain returned by admin.createPage(...).

| Method | Description | | --- | --- | | createPage({ title, path }) | Starts a page. title is shown in the UI; path is the route segment. | | .data(async ({ take, skip }) => rows) | Returns the rows for the list view. take / skip are provided for pagination. | | .primaryKey(name, type) | Declares the primary key field and its type (e.g. "id", "number"). Used to identify items. | | .item(async (id) => row) | Fetches a single record by its primary key for the item page. | | .table(columns) | Configures the list columns (see column options below). | | .createForm(schema, async (data) => {}) | Defines the "create" form and its submit handler. | | .updateForm(schema, async (itemId, data) => {}) | Defines the "update" form; receives the item's primary key and the new data. | | .onDelete(async (ids) => {}) | Bulk delete handler; receives an array of primary keys. |

Table columns

Each key in .table({ ... }) maps a data field to a column.

| Option | Description | | --- | --- | | title | Column header label. | | width | Column width in pixels. | | map | Derive the cell value from the row, e.g. item => item.id. Useful with the special _actions column. |

_actions is a reserved column key for action controls; combine it with map to pass the value the actions need.

Field options

Form schemas (compact-json-schema) and auth fields share the same field options:

| Option | Description | | --- | --- | | type | Field type, e.g. "string". | | label | Field label shown in the UI. | | width | Fraction of the row the field occupies, e.g. 0.5 for half-width. | | multiline | Render a multi-line text area instead of a single-line input. | | hidden | Mask the input (e.g. for passwords). |

Why marci (and Fastify)

@den59k/marci is a Bun-specific framework with a Fastify-like API. marci-admin targets that surface, so:

  • On Bun + marci you get the fastest path.
  • The API is close enough to Fastify that the panel can be moved over if your stack requires it.

License

MIT