DEV Community

Ethan Bennett
Ethan Bennett

Posted on

Auth + user data with zero database: building login on Vercel Blob (private store) in Next.js

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
Enter fullscreen mode Exit fullscreen mode

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",
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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)