For my side project — Deskling, a tiny desktop pet that only grows when you focus — I needed accounts so people could sync their pet across devices and appear on a leaderboard. I didn't want to provision Postgres for a hobby game, so I tried something simpler: Vercel Blob with a private store as a tiny key-value database.
It works surprisingly well for small apps. Here's the whole setup.
The data model
One JSON file per thing:
users/<username>.json -> { username, hash, salt, createdAt }
pets/<username>.json -> the pet state
leaderboard.json -> top 100 entries
Create the store as private (vercel blob create-store my-data --access private), so files can only be read with your server token — never by URL.
A tiny read/write layer
import { put, get } from "@vercel/blob";
export async function readJSON<T>(pathname: string): Promise<T | null> {
const res = await get(pathname, { access: "private", useCache: false });
if (!res || res.statusCode !== 200 || !res.stream) return null;
return JSON.parse(await new Response(res.stream).text()) as T;
}
export async function writeJSON(pathname: string, data: unknown) {
await put(pathname, JSON.stringify(data), {
access: "private",
addRandomSuffix: false, // stable path = key
allowOverwrite: true, // upsert
contentType: "application/json",
});
}
useCache: false matters: right after a write you want the fresh value, not a CDN copy.
Passwords: scrypt from Node's standard library
No bcrypt dependency needed:
import { scryptSync, randomBytes, timingSafeEqual } from "crypto";
export function hashPassword(pw: string, salt = randomBytes(16).toString("hex")) {
return { salt, hash: scryptSync(pw, salt, 64).toString("hex") };
}
export function verifyPassword(pw: string, user: { salt: string; hash: string }) {
const a = scryptSync(pw, user.salt, 64);
const b = Buffer.from(user.hash, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
Sessions: an HMAC-signed cookie
Instead of a sessions table, the cookie is the session: a base64 payload plus an HMAC signature.
import { createHmac } from "crypto";
const SECRET = process.env.SESSION_SECRET!;
const sign = (v: string) => createHmac("sha256", SECRET).update(v).digest("base64url");
export function makeToken(username: string) {
const payload = Buffer.from(JSON.stringify({ u: username, e: Date.now() + 60 * 864e5 })).toString("base64url");
return `${payload}.${sign(payload)}`;
}
export function readToken(t?: string) {
const [p, s] = (t ?? "").split(".");
if (!p || sign(p) !== s) return null;
const { u, e } = JSON.parse(Buffer.from(p, "base64url").toString());
return Date.now() < e ? (u as string) : null;
}
Set it with cookies().set(..., { httpOnly: true, secure: true, sameSite: "lax" }) from a Route Handler and you're done.
Register route
export async function POST(req: Request) {
const { username, password } = await req.json();
const u = String(username).trim().toLowerCase();
if (!/^[a-z0-9_]{3,20}$/.test(u)) return Response.json({ error: "Bad username" }, { status: 400 });
if (await readJSON(`users/${u}.json`)) return Response.json({ error: "Taken" }, { status: 409 });
await writeJSON(`users/${u}.json`, { username: u, ...hashPassword(password), createdAt: Date.now() });
await setSession(u);
return Response.json({ ok: true });
}
Validating the username with a strict regex is important — it becomes part of a storage path.
Trade-offs (be honest)
- No transactions. Two simultaneous registrations of the same name could race. For a hobby app that's acceptable; for money, use a real DB.
- Leaderboard is read-modify-write. I only rewrite it when the entry actually changes, which keeps writes low.
-
Listing is slow-ish, so don't design around
list()on hot paths — keep an index file instead. - Cost: for small JSON files it's essentially free on the hobby tier.
Guest first, account optional
The best UX decision: the game works instantly in localStorage. Creating an account just uploads the existing pet. Nobody has to sign up before they've fallen in love with their pet. 🐣
You can see it all running at deskling.vercel.app — hatch a pet, run a focus session, then hit "Save my pet" to try the auth flow. Questions or roasts of the approach welcome!
Top comments (0)