When I decided to extract my portfolio's inline editing logic into a standalone package, the first real design question was: how do I make it work on both Firebase and Postgres without duplicating everything?
The answer was three interfaces. DataAdapter, AuthAdapter, and StorageAdapter. The engine only ever talks to those. Whatever sits behind them is your problem.
This is what that looks like in practice.
The problem that forced this decision
My first portfolio was Firebase backed. When I started building a new one on Postgres, I had two options:
- Copy the CMS logic across and swap Firebase calls for Drizzle calls throughout.
- Make the backend a plugin.
Option one would mean maintaining two versions of the same logic forever. Option two meant I could ship a package and let both portfolios use it with different adapters plugged in.
The adapter pattern is not a new idea. But it was the right one here.
Three interfaces, one engine
The engine speaks three contracts. Everything else is implementation detail.
import type {
DataAdapter,
AuthAdapter,
StorageAdapter,
} from "@dalgoridim/headless-cms";DataAdapter handles your database. It does not care whether that is Firestore, Postgres, or something else entirely. It exposes seven methods: fetchById, fetchCollection, create, createWithId, update, upsert, delete. The engine calls those methods. That is the full surface.
AuthAdapter handles who is allowed to save. It receives the incoming request and resolves an identity. The gate then decides whether that identity is allowed to write.
StorageAdapter is split in two intentionally. The client half handles uploads via plain fetch. The server half handles signing and pulls in the actual SDK. They are separate entries because if you put them in the same module, Next.js traces the server SDK into the client bundle and you get a Can't resolve 'fs' error. Splitting the entry removes the trace entirely.
The neutral Query
The hardest part of making two backends swappable was the query layer. Firestore and SQL think about queries very differently. You cannot just expose native query types to the engine without leaking backend specifics everywhere.
So I wrote a neutral Query type that both adapters translate internally:
type Query = {
filters?: (QueryFilter | { or: QueryFilter[] })[];
orderBy?: { field: string; direction: "asc" | "desc" }[];
limit?: number;
offset?: number;
};Each adapter maps this onto whatever its backend speaks. PostgresDataAdapter translates it into Drizzle query builder calls (and, or, eq, ilike, inArray). FirestoreDataAdapter translates it into Firestore's chained query API.
Where a backend cannot honor an operator, it throws rather than returning wrong results. Firestore does not support case-insensitive substring search or OR groups at the top level. So contains and { or: [...] } throw on the Firestore adapter. You get a clear error instead of silently incorrect data.
// This throws on FirestoreDataAdapter
await data.fetchCollection("posts", {
filters: [{ field: "title", op: "contains", value: "hello" }],
});
// This works on both
await data.fetchCollection("posts", {
filters: [{ field: "status", op: "eq", value: "published" }],
orderBy: [{ field: "createdAt", direction: "desc" }],
limit: 20,
});What the two shipped adapters look like
FirestoreDataAdapter
You pass it your Firebase credentials and it handles everything:
const data = new FirestoreDataAdapter({
credentials: {
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY,
},
});The collection name maps directly to a Firestore collection. Field types are flexible since Firestore is schemaless.
PostgresDataAdapter
This one is different. You own the schema. The adapter does DML only, no DDL, no migrations. You declare your tables with Drizzle, run Drizzle Kit, and hand the adapter a connection and the schema map:
// lib/db/schema.ts
import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core";
export const projects = pgTable("projects", {
id: text("id").primaryKey(),
title: text("title"),
order: integer("order"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
});
export const schema = { projects } as const;const data = new PostgresDataAdapter({
connectionString: process.env.DATABASE_URL!,
schema,
});Every field that gets written must be a declared column. Writing an unknown field throws rather than silently landing in a JSONB catch-all. This is intentional. An earlier version had a schemaless extra column for unregistered fields. It hid drift behind silent writes. The current version surfaces it as an error.
An earlier version also hand-built SQL strings. That got replaced with Drizzle's query builder entirely. No string concatenation, no injection risk.
Wiring it up: only two of the four pieces change
A full install wires four pieces. The point of the adapter pattern is that only the first two change between backends:
| # | Piece | Firebase | Postgres |
|---|---|---|---|
| 1 | DataAdapter | FirestoreDataAdapter | PostgresDataAdapter |
| 2 | AuthAdapter | firebaseAuth | googleAuth / nextAuthAuth / custom |
| 3 | Admin route | createCmsHandlers({ data, auth }) | identical |
| 4 | Client | PageProvider + auth provider + primitives | identical |
The admin route is the same regardless:
export const { GET, PATCH, PUT, DELETE } = createCmsHandlers({ data, auth });The client components (ContentEditSpan, EditableImage, usePageContext) have no idea what backend is running. They call editField, saveAll, createItem. The engine handles the rest.
Writing your own adapter
The engine only speaks the DataAdapter interface. If you want Prisma, Kysely, or something entirely custom, implement seven methods and pass it in:
import type { DataAdapter } from "@dalgoridim/headless-cms";
class MyCustomAdapter implements DataAdapter {
async fetchById(collection: string, id: string) { ... }
async fetchCollection(collection: string, query?: Query) { ... }
async create(collection: string, data: Record<string, unknown>) { ... }
async createWithId(collection: string, id: string, data: Record<string, unknown>) { ... }
async update(collection: string, id: string, patch: Record<string, unknown>) { ... }
async upsert(collection: string, id: string, data: Record<string, unknown>) { ... }
async delete(collection: string, id: string) { ... }
}Plug it in the same way:
const data = new MyCustomAdapter();
export const { GET, PATCH, PUT, DELETE } = createCmsHandlers({ data, auth });The rest of the package does not change.
The bundle boundary problem
One thing the adapter split solved that I did not initially plan for was bundle safety. Optional peers only get loaded when you import the specific subpath that needs them.
A Firestore-only app never installs drizzle-orm or pg. A Postgres-only app never installs firebase-admin. The adapters live behind /adapters/firestore and /adapters/postgres respectively, so neither is pulled into the bundle unless you explicitly import it.
Same for storage. Cloudinary's server SDK lives at @dalgoridim/headless-cms/storage/cloudinary/server. The client upload adapter lives at @dalgoridim/headless-cms/storage/cloudinary. Two entries, two bundles, no leakage.
What this actually cost
Making the backends properly swappable was the hardest part of building the package. The neutral Query type took the most iteration. Getting the Postgres adapter off hand-built SQL and onto Drizzle properly was a full rewrite. The bundle boundary issue with storage adapters took a day to track down.
But the result is that my 2025 portfolio and my 2026 portfolio both run @dalgoridim/headless-cms. One on Firebase, one on Postgres. Same package, same version, different adapter. That is the whole point.
npm install @dalgoridim/headless-cms