Quick start
Two routes on the server, two calls in the browser.
The whole flow is two routes on your server and two calls from the browser.
The bucket
// lib/store.ts
import { createBucket } from 's3nd'
export const store = createBucket({
bucket: process.env.S3_BUCKET,
region: process.env.AWS_REGION,
prefix: 'snapshots',
maxSize: 4 * 1024 * 1024,
})
export const SCHEMA_VERSION = 3Codes come from store.codes, in whatever shape syncCode configures — eight Crockford base32
characters unless you say otherwise. Configuring it on the store is what keeps generation and
normalization agreeing about the alphabet. See Sync codes.
createBucket() is cheap — the underlying S3Client is built on the first request — so calling
it at module scope is fine. maxSize set below your platform's request limit turns an oversized
snapshot into a clean 413 rather than a truncated request.
Sending
// app/api/sync/route.ts
import { store, SCHEMA_VERSION } from '@/lib/store'
export async function POST(request: Request) {
const state = await request.json()
const code = store.codes.create()
await store.putSnapshot(code, state, {
app: 'notes',
version: SCHEMA_VERSION,
device: request.headers.get('user-agent') ?? undefined,
expiresIn: 60 * 60,
ifAbsent: true,
})
return Response.json({ code })
}Two options are doing quiet work. expiresIn keeps a transfer code from outliving the transfer —
it is a bearer token, and a short life is most of its security. ifAbsent refuses to write if
something already sits under that code, so a collision fails loudly instead of overwriting a
stranger's snapshot.
Receiving
// app/api/sync/[code]/route.ts
import { store, SCHEMA_VERSION } from '@/lib/store'
export async function GET(_request: Request, { params }: { params: Promise<{ code: string }> }) {
const { code } = await params
const snapshot = await store.getSnapshot(store.codes.normalize(code), { maxVersion: SCHEMA_VERSION })
if (!snapshot) {
return Response.json({ error: 'Unknown or expired code' }, { status: 404 })
}
return Response.json({
data: snapshot.data,
createdAt: snapshot.createdAt,
device: snapshot.device,
})
}store.codes.normalize() is what makes a typed code work: it drops separators, folds case, and
repairs the characters people misread. Call it on user input, always.
getSnapshot() returns null for a code that was never used and for one that has expired — the
receiving device does not need to tell those apart. maxVersion makes a snapshot from a newer
build throw rather than land in an app that will misread it.
The client half
Reading IndexedDB out and writing it back in is your application's code, because only it knows its own object stores. The shape:
export async function sendToOtherDevice() {
const state = await exportDatabase() // your dump: { [storeName]: records[] }
const response = await fetch('/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state),
})
const { code } = await response.json()
return code // show it to the user
}
export async function restoreFromCode(code: string) {
const response = await fetch(`/api/sync/${encodeURIComponent(code)}`)
if (!response.ok) throw new Error('Unknown or expired code')
const { data, createdAt, device } = await response.json()
// Confirm before replacing: a restore overwrites what is on this device.
await importDatabase(data)
return { createdAt, device }
}The IndexedDB example
has a complete exportDatabase / importDatabase pair against a real object store.
Next
- Snapshots — what is in the envelope and why.
- Sync codes — alphabet, normalization, and how guessable a code really is.
- Two devices, one snapshot — when both sides write.