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.

yourapp.com/recipes

Your recipes

4 saved

+ Add recipe
Title
Minutes
Tags
Ramen
20
quicksoup
Focaccia
90
bread
Tacos al pastor
45
mexican
Miso-glazed salmon
25
fishquick

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

New recipe

Ramen
20
quick, soup
Cancel
Save 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. |