6 min read

Anonymous-first auth with better-auth + Postgres

How I killed the signup wall: mint a real (anonymous) user the moment someone contributes, then merge their data into a real account on login, with a Postgres transaction so nothing gets orphaned.

nextjstypescriptauthenticationwebdev

TL;DR: Don't make people sign up before they've gotten any value. Mint a real but anonymous user row the instant they do something worth keeping, let your foreign keys point at it like any other user, and when they finally claim the account via magic link, reassign their rows inside a single Postgres transaction. No userId | null smeared across your schema, no "log in to continue" wall, no orphaned data.

I run Paws Inside, a directory of cafés and venues where dogs are welcome inside (not chained to a railing in the rain). It started, as these things do, with one poodle named Bentley and one too many awkward "...sorry, are dogs allowed inside?" doorway conversations.

People want to add their dog, upload a photo of him judging your flat white, suggest a venue. The classic way to handle that is a wall: sign up to continue. And the classic result is that most of them don't.

So I didn't build the wall. Here's the pattern I used instead, and the one sharp edge that matters.

The idea: a real user, who just doesn't know it yet

The usual "guest mode" approach is to bolt nullable user IDs onto everything and special-case the logged-out path through your whole data layer. It rots fast. Every query grows an OR session_id = ? branch and every insert has a userId: string | null you have to reason about forever.

Instead, the moment someone is about to contribute something, I silently create a real user row that happens to be flagged anonymous. better-auth's anonymous plugin does exactly this:

import { betterAuth } from "better-auth";
import { anonymous, magicLink, bearer } from "better-auth/plugins";
import { pool } from "@/server/db/pool";

export const auth = betterAuth({
  plugins: [
    bearer(),
    anonymous({
      onLinkAccount: async ({ anonymousUser, newUser }) => {
        // ...the important bit (see below)
      },
    }),
    magicLink({
      sendMagicLink: async ({ email, url }) => {
        /* Resend */
      },
    }),
  ],
});

On the client, the same plugin gives you signIn.anonymous(). I never call it on page load. That would mint a junk user for every bot and every bouncing visitor. I call it lazily, the first time someone actually reaches for something that needs an identity:

const { data: session } = useSession();

/** Ensure an anonymous session exists before the first contribution. */
const ensureSession = async () => {
  if (!session) {
    await signIn.anonymous();
  }
};

So the flow is: visitor lands, reads, browses the map, no account, no cookie noise. They click "Add your dog", ensureSession() quietly mints user#abc (isAnonymous: true), and the pet row inserts with user_id = abc like any other.

The payoff: the rest of my code has no idea anonymous users exist. pet.user_id is NOT NULL and foreign-keys to user.id. Media uploads and pet photos all point at a normal user. There is no second code path. The "guest" is just a user wearing a name tag that says isAnonymous.

The sharp edge: signing up creates a new user

Here's the part people miss. When that anonymous user eventually decides to make it official and signs in with their email, your auth library, quite reasonably, creates a brand new user row for that email. Different ID.

Which means, if you do nothing, the magic-link login you just celebrated has orphaned every pet and photo they created as a guest. They log in for the first time and... their dog is gone. Worst possible moment to lose someone's data.

This is the whole reason onLinkAccount exists. better-auth detects "there's an active anonymous session and this person is now authenticating as a real account," and before it swaps the session it hands you both users so you can reconcile the data:

anonymous({
  onLinkAccount: async ({ anonymousUser, newUser }) => {
    const client = await pool.connect();
    try {
      await client.query("BEGIN");

      await client.query("UPDATE public.media SET user_id = $1 WHERE user_id = $2", [
        newUser.user.id,
        anonymousUser.user.id,
      ]);

      await client.query("UPDATE public.pet SET user_id = $1 WHERE user_id = $2", [
        newUser.user.id,
        anonymousUser.user.id,
      ]);

      await client.query("COMMIT");
    } catch (error) {
      await client.query("ROLLBACK");
      console.error("[Anonymous] Failed to link account data:", error);
    } finally {
      client.release();
    }
  },
});

A few things worth underlining.

It's a transaction, not two loose updates. The pets and the photos of those pets have to move together. If pet reassigns but media fails halfway, you've got a user whose dog exists but whose photos belong to a ghost account. BEGIN, COMMIT, ROLLBACK: the merge lands in full or it doesn't land at all, and on failure the guest data stays put under the anon ID, where you can still go and recover it.

The schema does most of the work for free. Because guest data already lived under a real user_id, "merging accounts" is just UPDATE ... SET user_id. No JSON blob to migrate, no temp tables, two columns to repoint. You bought that simplicity way back when you decided anonymous users would be real users.

And don't throw out of the callback. A failure here shouldn't block the login. The person should still land in their (now sadly empty) account rather than an error page. Catch it, roll back, log loudly, reconcile later. Losing the merge is recoverable. Blocking the login is how you get someone to close the tab for good.

Bonus: make the guest session outlive the visit

The pattern only pays off if the guest is still the same guest when they come back next week. I lean on stateless, long-lived JWT sessions so the anonymous identity survives without a server-side session table to babysit:

A visitor can add Bentley today, wander off, come back in a month, and the dog is still there. All of that before they've typed an email address. When they finally do, the merge feels like a no-op formality rather than a data-recovery event.

What I'd watch out for

A few things I learned the slightly harder way:

  • Mint the anon user lazily, never on page load. Call signIn.anonymous() on load and you'll manufacture a user for every crawler and every one-second bounce. Gate it behind the first real action.
  • Reap the orphans. Anonymous users who never convert and never contribute still pile up. Run a job that deletes anon users with no rows attached after N days, or you'll pay to store millions of ghosts.
  • Expect the merge to mostly do nothing. Most logins have no anon session to link, so onLinkAccount never fires and your normal sign-in path can't depend on it having run.
  • Sort out your conflict policy before you need it. Reassigning user_id is clean when the target account is brand new. If someone can sign into an existing account while holding guest data, decide whether you merge, keep both, or refuse. Don't find that out in prod.

The dog-friendly-venue stuff is niche, but this pattern isn't. Anything where a user makes something valuable before you've earned the right to ask who they are (playlists, carts, drafts, saved searches) wants anonymous-first auth.

Killing the signup wall was the best UX change I've made on the site. Funny that it lived almost entirely in one auth callback and two UPDATE statements.