Skip to content
WrongNotebook
ProductsPersonal BaaSDocumentationQuickstart
Sign inCreate workspace
Browse documentation

Start here

  • Quickstart
  • API keys and scopes

Working with data

  • Collections
  • Records
  • Filtering, sorting and paging
  • Schemas and validation
  • Files

Integrate

  • Webhooks
  • Recipes

Reference

  • Errors
  • Limits and rate limits

Start here

  • Quickstart
  • API keys and scopes

Working with data

  • Collections
  • Records
  • Filtering, sorting and paging
  • Schemas and validation
  • Files

Integrate

  • Webhooks
  • Recipes

Reference

  • Errors
  • Limits and rate limits
  1. WrongNotebook
  2. /
  3. Docs
  4. /
  5. Recipes

Recipes

Complete copy-paste examples: a contact form, a todo app, a public JSON feed for a static site, and a backup script.

Working examples you can paste in and adapt. Each one says what it needs.

Set these first:

Terminal
BAAS_URL=https://www.wrongnotebook.com/v1
BAAS_KEY=pb_live_…        # keep this on the server
BAAS_WS=…                 # from GET /v1/me

A contact form that stores submissions

Needs: a collection messages, and a key with records:create only. A create-only key is the whole trick: if it leaks, nobody can read your submissions.

JavaScript
// app/api/contact/route.js — Next.js App Router
export async function POST(request) {
  const form = await request.formData();
  const name = String(form.get('name') ?? '').trim();
  const email = String(form.get('email') ?? '').trim();
  const message = String(form.get('message') ?? '').trim();

  if (!name || !email || !message)
    return Response.json({ error: 'Please fill in every field.' }, { status: 400 });

  const res = await fetch(
    `${process.env.BAAS_URL}/workspaces/${process.env.BAAS_WS}/collections/messages/records`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.BAAS_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        data: { name, email, message, receivedAt: new Date().toISOString() },
      }),
    },
  );

  if (!res.ok) {
    const body = await res.json();
    console.error('contact form failed', body.error?.requestId, body.error?.code);
    return Response.json({ error: 'Could not send. Please try again.' }, { status: 502 });
  }
  return Response.json({ ok: true });
}

Read the messages in the dashboard, or add a webhook so each one pings you in Slack.

A todo app

Needs: a collection todos, and a key with records:read, records:create, records:update, records:delete.

JavaScript
const BASE = `${process.env.BAAS_URL}/workspaces/${process.env.BAAS_WS}/collections/todos`;
const headers = {
  Authorization: `Bearer ${process.env.BAAS_KEY}`,
  'Content-Type': 'application/json',
};

export async function listTodos({ done } = {}) {
  const query = new URLSearchParams({ sort: '-createdAt', limit: '100' });
  if (done !== undefined) query.set('filter', `data.done == ${done}`);
  const res = await fetch(`${BASE}/records?${query}`, { headers });
  const body = await res.json();
  if (!res.ok) throw new Error(body.error.message);
  return body.data.map((row) => ({ id: row.id, version: row.version, ...row.data }));
}

export async function addTodo(title) {
  const res = await fetch(`${BASE}/records`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ data: { title, done: false } }),
  });
  if (!res.ok) throw new Error((await res.json()).error.message);
  return res.json();
}

export async function toggleTodo(id, done, version) {
  const res = await fetch(`${BASE}/records/${id}`, {
    method: 'PATCH',
    headers,
    // expectedVersion means a stale tab cannot silently overwrite a newer change
    body: JSON.stringify({ data: { done }, expectedVersion: version }),
  });
  if (res.status === 409) throw new Error('This task changed elsewhere. Refresh and retry.');
  if (!res.ok) throw new Error((await res.json()).error.message);
  return res.json();
}

export async function removeTodo(id) {
  const res = await fetch(`${BASE}/records/${id}`, { method: 'DELETE', headers });
  if (!res.ok) throw new Error((await res.json()).error.message);
}

Deleting is a soft delete, so a mistake can be undone — see Records.

A public JSON feed for a static site

Needs: a collection with visibility Public read. No key at all, so this is safe to call straight from the browser or at build time.

JavaScript
// Next.js page, rebuilt every 10 minutes
export const revalidate = 600;

export default async function Changelog() {
  const res = await fetch(
    'https://www.wrongnotebook.com/v1/public/collections/COLLECTION_ID/records?sort=-createdAt&limit=20',
  );
  const { data } = await res.json();

  return (
    <ul>
      {data.map((entry) => (
        <li key={entry.id}>
          <h2>{entry.data.title}</h2>
          <p>{entry.data.summary}</p>
        </li>
      ))}
    </ul>
  );
}

Public collections use the collection's UUID, not its slug. Remember that everything in one is readable by anyone.

Rebuild a static site when content changes

Needs: a webhook pointing at this route, subscribed to record.created, record.updated and record.deleted.

JavaScript
// app/api/hooks/baas/route.js
import crypto from 'node:crypto';

export async function POST(request) {
  const raw = await request.text();
  const timestamp = request.headers.get('x-baas-timestamp') ?? '0';
  const signature = request.headers.get('x-baas-signature') ?? '';

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
    return new Response('stale', { status: 400 });

  const expected =
    'v1=' +
    crypto
      .createHmac('sha256', process.env.WEBHOOK_SECRET)
      .update(`${timestamp}.${raw}`)
      .digest('hex');
  if (
    signature.length !== expected.length ||
    !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  )
    return new Response('bad signature', { status: 401 });

  // Reply fast, then do the work.
  await fetch(process.env.DEPLOY_HOOK_URL, { method: 'POST' });
  return new Response(null, { status: 204 });
}

Back everything up on a schedule

Needs: a key with records:read. Run it from cron or a scheduled job.

Terminal
#!/usr/bin/env bash
set -euo pipefail

DATE=$(date +%F)
mkdir -p "backups/$DATE"

for SLUG in todos messages bookmarks; do
  curl -sS --fail \
    "$BAAS_URL/workspaces/$BAAS_WS/collections/$SLUG/export?format=ndjson" \
    -H "Authorization: Bearer $BAAS_KEY" \
    -o "backups/$DATE/$SLUG.ndjson"
  echo "saved $SLUG"
done

NDJSON can be imported straight back, so this doubles as a restore point. See import and export.

Move every record through a change

Needs: records:read and records:update. This walks the whole collection by cursor rather than loading it all into memory.

JavaScript
let cursor = null;
do {
  const query = new URLSearchParams({ limit: '100' });
  if (cursor) query.set('cursor', cursor);
  const page = await (await fetch(`${BASE}/records?${query}`, { headers })).json();

  for (const row of page.data) {
    if (row.data.priority) continue; // already done
    await fetch(`${BASE}/records/${row.id}`, {
      method: 'PATCH',
      headers,
      body: JSON.stringify({ data: { priority: 'medium' } }),
    });
  }
  cursor = page.pagination.nextCursor;
} while (cursor);

Try it on a duplicate of the collection first.

Next steps

  • Filtering and sorting — narrowing down what you fetch.
  • Errors — handling failures properly.

View this page as Markdown — handy for copying into an editor or an AI assistant.

PreviousWebhooksNextErrors

On this page

  • A contact form that stores submissions
  • A todo app
  • A public JSON feed for a static site
  • Rebuild a static site when content changes
  • Back everything up on a schedule
  • Move every record through a change
  • Next steps
WrongNotebook

Personal BaaS is one focused product from WrongNotebook.

Documentation

  • All pages
  • Quickstart
  • Recipes
  • Errors

Product

  • Personal BaaS
  • All products
  • View the demo

Get started

  • Sign in
  • Create workspace

© 2026 WrongNotebook. All rights reserved.