Data
app.db.collection(name) returns a collection reference scoped to the current tenant.
Records are JSON objects with automatic id, created_at, and updated_at; there is no
schema to declare ahead of time.
Your recipes
4 saved
CRUD
const recipes = app.db.collection('recipes');
await recipes.insert({ title: 'Ramen', minutes: 20 });
const page = await recipes.find(); // { records, total }
const one = await recipes.findOne(page.records[0].id);
await recipes.update(one.id, { minutes: 25 }); // merge, not replace
await recipes.delete(one.id);insert returns the created record with its generated id. update performs a shallow
merge into the existing record's JSON — fields you do not send are preserved.
Filters, sort, pagination
const page = await recipes.find(
{ minutes: { $lte: 30 }, tags: { $contains: 'quick' } },
{ sort: [['created_at', 'desc']], limit: 20, offset: 0 },
);| Operator | Meaning |
| --- | --- |
| $eq, $ne | equal / not equal |
| $in, $nin | value is / is not in a set |
| $lt, $lte, $gt, $gte | numeric / lexical comparison |
| $contains | array contains the given element |
Top-level keys are AND-composed. Nested field paths are not supported in v1 — keep
fields flat.
Unique fields
Declare a unique field in the dashboard (per app, per collection). Duplicate inserts or
updates return a 409 duplicate_unique_field, surfaced as a PluralizeError:
try {
await recipes.insert({ slug: 'ramen', title: 'Ramen' });
await recipes.insert({ slug: 'ramen', title: 'Another' });
} catch (err) {
// err.code === 'duplicate_unique_field'
}Upsert by unique field
When you want "create if missing, update if present" against a unique field:
await recipes.upsertBy('slug', { slug: 'ramen', title: 'Ramen', minutes: 25 });Share links
Generate a time-limited public URL for a single record:
const { token, url } = await recipes.createShareLink(one.id, {
expiresIn: 60 * 60 * 24, // 24 hours, in seconds
});Anyone holding the token can read the record via GET /api/v1/public/shared/:token, no
auth needed. Use recipes.revokeShareLinks(one.id) to invalidate all outstanding tokens
for that record.
Full example — a recipes page
A complete Next.js App Router page: list the current user's recipes, add new ones, delete rows inline.
New recipe
Collection hook
Extract the fetching logic into a reusable hook so every page that touches a collection stays terse.
// lib/use-collection.ts
'use client';
import { useCallback, useEffect, useState } from 'react';
import { usePluralize } from '@pluralize/sdk/react';
import type { DataRecord } from '@pluralize/sdk';
export function useCollection<T extends object>(name: string) {
const app = usePluralize();
const [records, setRecords] = useState<DataRecord[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
const { records } = await app.db.collection(name).find();
setRecords(records);
setLoading(false);
}, [app, name]);
useEffect(() => {
refresh();
}, [refresh]);
const insert = useCallback(
async (data: T) => {
await app.db.collection(name).insert(data);
await refresh();
},
[app, name, refresh],
);
const remove = useCallback(
async (id: string) => {
await app.db.collection(name).delete(id);
setRecords((prev) => prev.filter((r) => r.id !== id));
},
[app, name],
);
return { records, loading, insert, remove, refresh };
}Page
// app/recipes/page.tsx
'use client';
import { useState } from 'react';
import { useCollection } from '@/lib/use-collection';
import { PluralizeError } from '@pluralize/sdk';
type Recipe = { title: string; minutes: number; tags: string[] };
export default function RecipesPage() {
const { records, loading, insert, remove } = useCollection<Recipe>('recipes');
const [title, setTitle] = useState('');
const [minutes, setMinutes] = useState(15);
const [error, setError] = useState<string | null>(null);
async function onAdd(event: React.FormEvent) {
event.preventDefault();
setError(null);
try {
await insert({ title, minutes, tags: [] });
setTitle('');
setMinutes(15);
} catch (err) {
if (err instanceof PluralizeError && err.code === 'duplicate_unique_field') {
setError('A recipe with that title already exists.');
} else if (err instanceof PluralizeError && err.code === 'limit_reached') {
setError('You have reached your plan limit. Upgrade to add more.');
} else {
setError('Could not save. Try again.');
}
}
}
if (loading) return <p className="p-6 text-sm text-muted-foreground">Loading…</p>;
return (
<main className="mx-auto max-w-2xl space-y-6 p-6">
<form onSubmit={onAdd} className="flex gap-2">
<input
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Recipe title"
className="flex-1 rounded-md border px-3 py-2 text-sm"
/>
<input
type="number"
min={1}
value={minutes}
onChange={(e) => setMinutes(Number(e.target.value))}
className="w-20 rounded-md border px-3 py-2 text-sm"
/>
<button className="rounded-md bg-black px-4 py-2 text-sm text-white">Add</button>
</form>
{error && <p className="text-sm text-red-600">{error}</p>}
<ul className="divide-y rounded-md border">
{records.map((r) => (
<li key={r.id} className="flex items-center justify-between px-3 py-2 text-sm">
<span>
{(r.data as Recipe).title}{' '}
<span className="text-muted-foreground">
— {(r.data as Recipe).minutes} min
</span>
</span>
<button
onClick={() => remove(r.id)}
className="text-xs text-muted-foreground hover:text-red-600"
>
Delete
</button>
</li>
))}
</ul>
</main>
);
}Error reference
| Status | code | Cause |
| --- | --- | --- |
| 400 | invalid_data | Body was not a JSON object or exceeded size limits. |
| 402 | limit_reached | Tenant is at the max_<collection> cap. Body includes { feature, limit, current }. |
| 404 | not_found | Record does not exist or belongs to another tenant. |
| 409 | duplicate_unique_field | A declared unique field collided. |