Skip to content

better-content/server

Server-side building blocks over web-standard Request/Response. No framework dependency; works in Next.js route handlers, Remix, Hono, Bun, or plain Node 18+.

createCmsHandlers

ts
function createCmsHandlers(deps: CmsHandlersDeps): {
  GET: RouteHandler;
  PUT: RouteHandler;
  PATCH: RouteHandler;
  DELETE: RouteHandler;
  sign: (req: Request) => Promise<Response>;
};

interface CmsHandlersDeps {
  data: DataAdapter;
  auth: AuthAdapter;
  storage?: ServerStorageAdapter;   // enables the sign handler
  authorize?: AuthorizeFn;          // default: identity.isAdmin === true
  onError?: (error: unknown) => void;  // default: console.error
}

type RouteHandler = (
  req: Request,
  ctx: { params: Promise<{ collection: string; id: string }> },
) => Promise<Response>;

Route semantics at {base}/{collection}/{id}:

HandlerAdapter callSuccessErrors
GETfetchById200 document404 when missing
PUTupsert (body){ ok: true }400 invalid body
PATCHupdate (body){ ok: true }400 invalid body
DELETEdelete{ ok: true }
signstorage.signsigner payload404 without storage

Every handler runs the admin gate first: 401 { error, logout: true } for unverified requests, 403 for verified non-admins. Bodies must be JSON objects.

Anything else (adapter, storage, or driver failures) is passed to onError and answered with a generic 500 { error: "Request failed" }. The underlying message never reaches the client, because adapter errors routinely contain query text and parameter values. Pass your own onError to route these to a real logger.

createAdminGate

ts
function createAdminGate(auth: AuthAdapter, authorize?: AuthorizeFn):
  (req: Request) => Promise<AuthIdentity>;

class UnauthorizedError extends Error {
  status: 401 | 403;
}

The same gate the handlers use, exported for guarding your own routes. It throws UnauthorizedError; catch it and map status yourself.

loadItemMap

ts
function loadItemMap(data: DataAdapter, collections: ItemMapLoadConfig): Promise<ItemMap>;

type ItemMapLoadConfig = Record<string, {
  query?: Query;
  defaults?: Item[];              // used with merge: "byId"
  merge?: "replace" | "byId";     // default "replace"
  fallback?: Item[];              // used only when the fetch throws
}>;

Loads collections concurrently into the ItemMap shape PageProvider hydrates from.

  • merge: "byId" layers fetched rows over defaults by id: stored fields win, default-only fields survive, rows without defaults append.
  • fallback applies only when the fetch throws (an empty result is a valid result). Without a fallback the error propagates.

seedItemMap

ts
function seedItemMap(
  data: DataAdapter,
  collections: ItemMapSeedConfig,
  options?: { mode?: SeedMode },     // default "byId"
): Promise<void>;

type SeedMode = "byId" | "replace";
type ItemMapSeedConfig = Record<string, Item[] | { items: Item[]; mode?: SeedMode }>;

Writes an ItemMap into a backend. The mirror of loadItemMap, and it borrows that function's vocabulary rather than inventing new words.

ts
await seedItemMap(data, {
  portfolio: sections,                        // byId, the default
  projects: { items: projects, mode: "replace" },
});
  • "byId" replaces the named records and leaves everything else in the collection alone.
  • "replace" makes the seeded array the collection: records in the backend but absent from the seed are deleted.

The mode is per collection because a real seed needs both in one run: singletons written by id, lists replaced wholesale. options.mode sets the default for collections that do not name their own.

A bare Item[] is accepted, so an ItemMap is already a valid argument and seedItemMap(target, await loadItemMap(source, …)) copies an environment.

Each record is written as delete then createWithId. Neither verb expresses a portable replace alone: createWithId rejects an existing id, and upsert merges rather than replaces and does not stamp createdAt — which on Firestore means the record is invisible to a default read that orders by it. Routing every write through createWithId is what avoids that.

What that costs, stated rather than discovered:

  • Two round trips per record. Seeding is an offline one-shot.
  • createdAt is reset for a record that already existed. Correct for a replace, and it makes default read order reflect the seed run.
  • Not atomic. The seam has no batch or transaction, so a record can be deleted and not yet rewritten. Writes run sequentially in a deterministic order and the first failure throws, naming the collection, the id and how many writes had landed — and saying so explicitly when the record was deleted before the write failed.

id and collection are treated as the record's address, not its fields, and are not written into the document.

There is deliberately no "merge" mode. Merging new fields into existing content is a migration concern, and it is the one shape that carries the createdAt trap above. Call data.upsert directly if you want it.

Ordering is not touched and no order field is invented: both adapters stamp millisecond timestamps, so a fast loop ties and read order becomes arbitrary. Put an explicit order field in the items and sort on it.

createContentHandler

ts
function createContentHandler(deps: {
  data: DataAdapter;
  collections: ItemMapLoadConfig;   // same shape loadItemMap takes
  auth?: AuthAdapter;               // omit for a public endpoint
  authorize?: AuthorizeFn;
  cacheControl?: string;            // default "no-store"
  onError?: (error: unknown) => void;
}): { GET: (req?: Request) => Promise<Response> };

The public read half of the CMS: it returns the same ItemMap a server-rendered page would build with loadItemMap, as JSON.

createCmsHandlers covers writes. Its GET is admin-gated and fetches a single document by id, so it cannot answer "the content for this page". Any app rendering on the client needs this route instead, paired with fetchItemMap.

ts
// Next.js: src/app/api/content/route.ts
import { createContentHandler } from "better-content/server";
import { data } from "@/lib/data";

export const { GET } = createContentHandler({
  data,
  collections: {
    sections: {
      defaults: [{ id: "hero", heading: "Edit this heading" }],
      merge: "byId",
    },
  },
});

It is public by default, because it only ever reads the content the page already shows. Pass auth to gate it, for a site whose content is not public; note that a browser visitor then cannot read it either, which is usually not what you want.

Adapter failures answer { error: "Request failed" } with a 500 and go to onError (default console.error), the same as createCmsHandlers, so query text and parameter values never reach the client.

resolveRelations

ts
function resolveRelations<T>(adapter: DataAdapter, docs: T | T[], options?: {
  populate?: string[];            // default: every field named in relations
  relations?: RelationConfig;     // maps bare-id fields → target collection
}): Promise<T | T[]>;

Inline-resolves reference fields on one or many documents. A reference is a self-describing { collection, id } object, a bare id string mapped through relations, or an array of either (resolved element-wise). Loads are deduplicated: each unique (collection, id) fetches once across the whole batch. Unresolvable references (fetch returns null) are left untouched. Mutates and returns the input.

MIT. Independent project, not affiliated with the better-* family.