---
title: Recipes
description: Complete copy-paste examples: a contact form, a todo app, a public JSON feed for a static site, and a backup script.
group: Integrate
order: 2
keywords: [examples, code samples, nextjs backend, contact form backend, static site cms, backup script]
---

# Recipes

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

Set these first:

```bash
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.

```js
// 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](/docs/webhooks) 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`.

```js
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](/docs/records#delete-and-restore).

## 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.

```js
// 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](/docs/webhooks) pointing at this route, subscribed to
`record.created`, `record.updated` and `record.deleted`.

```js
// 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.

```bash
#!/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](/docs/collections#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.

```js
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](/docs/collections#duplicate-archive-and-delete)
first.

## Next steps

- [Filtering and sorting](/docs/queries) — narrowing down what you fetch.
- [Errors](/docs/errors) — handling failures properly.
