<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Paws Inside</title>
    <description>The latest articles on DEV Community by Paws Inside (@pawsinside).</description>
    <link>https://dev.to/pawsinside</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3970548%2F652d5e92-c62e-4b02-b766-f428f467f05e.png</url>
      <title>DEV Community: Paws Inside</title>
      <link>https://dev.to/pawsinside</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pawsinside"/>
    <language>en</language>
    <item>
      <title>Anonymous-first auth with better-auth + Postgres</title>
      <dc:creator>Paws Inside</dc:creator>
      <pubDate>Fri, 05 Jun 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/pawsinside/anonymous-first-auth-with-better-auth-postgres-56ck</link>
      <guid>https://dev.to/pawsinside/anonymous-first-auth-with-better-auth-postgres-56ck</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Don't make people sign up before they've gotten any value. Mint a &lt;em&gt;real but anonymous&lt;/em&gt; 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 &lt;code&gt;userId | null&lt;/code&gt; smeared across your schema, no "log in to continue" wall, no orphaned data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I run &lt;a href="https://pawsinside.com" rel="noopener noreferrer"&gt;Paws Inside&lt;/a&gt;, a directory of cafés and venues where dogs are welcome &lt;em&gt;inside&lt;/em&gt; (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 &lt;em&gt;inside&lt;/em&gt;?" doorway conversations.&lt;/p&gt;

&lt;p&gt;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: &lt;strong&gt;sign up to continue.&lt;/strong&gt; And the classic result is that most of them don't.&lt;/p&gt;

&lt;p&gt;So I didn't build the wall. Here's the pattern I used instead, and the one sharp edge that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: a real user, who just doesn't know it yet
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;OR session_id = ?&lt;/code&gt; branch and every insert has a &lt;code&gt;userId: string | null&lt;/code&gt; you have to reason about forever.&lt;/p&gt;

&lt;p&gt;Instead, the moment someone is about to &lt;em&gt;contribute&lt;/em&gt; something, I silently create a real user row that happens to be flagged anonymous. &lt;a href="https://better-auth.com" rel="noopener noreferrer"&gt;better-auth&lt;/a&gt;'s &lt;code&gt;anonymous&lt;/code&gt; plugin does exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;betterAuth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;better-auth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;anonymous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;magicLink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bearer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;better-auth/plugins&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/server/db/pool&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;betterAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;bearer&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nf"&gt;anonymous&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;onLinkAccount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;anonymousUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newUser&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...the important bit (see below)&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;magicLink&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;sendMagicLink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="cm"&gt;/* Resend */&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="cm"&gt;/** Ensure an anonymous session exists before the first contribution. */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ensureSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymous&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The payoff: the rest of my code has no idea anonymous users exist. &lt;code&gt;pet.user_id&lt;/code&gt; is &lt;code&gt;NOT NULL&lt;/code&gt; and foreign-keys to &lt;code&gt;user.id&lt;/code&gt;. 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 &lt;code&gt;isAnonymous&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sharp edge: signing up creates a &lt;em&gt;new&lt;/em&gt; user
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is the whole reason &lt;code&gt;onLinkAccount&lt;/code&gt; exists. better-auth detects "there's an active anonymous session &lt;em&gt;and&lt;/em&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;anonymous&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;onLinkAccount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;anonymousUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newUser&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UPDATE public.media SET user_id = $1 WHERE user_id = $2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;newUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;anonymousUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UPDATE public.pet SET user_id = $1 WHERE user_id = $2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;newUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;anonymousUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;COMMIT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[Anonymous] Failed to link account data:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth underlining.&lt;/p&gt;

&lt;p&gt;It's a transaction, not two loose updates. The pets and the photos of those pets have to move together. If &lt;code&gt;pet&lt;/code&gt; reassigns but &lt;code&gt;media&lt;/code&gt; fails halfway, you've got a user whose dog exists but whose photos belong to a ghost account. &lt;code&gt;BEGIN&lt;/code&gt;, &lt;code&gt;COMMIT&lt;/code&gt;, &lt;code&gt;ROLLBACK&lt;/code&gt;: 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.&lt;/p&gt;

&lt;p&gt;The schema does most of the work for free. Because guest data already lived under a real &lt;code&gt;user_id&lt;/code&gt;, "merging accounts" is just &lt;code&gt;UPDATE ... SET user_id&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: make the guest session outlive the visit
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd watch out for
&lt;/h2&gt;

&lt;p&gt;A few things I learned the slightly harder way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mint the anon user lazily, never on page load. Call &lt;code&gt;signIn.anonymous()&lt;/code&gt; on load and you'll manufacture a user for every crawler and every one-second bounce. Gate it behind the first real action.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Expect the merge to mostly do nothing. Most logins have no anon session to link, so &lt;code&gt;onLinkAccount&lt;/code&gt; never fires and your normal sign-in path can't depend on it having run.&lt;/li&gt;
&lt;li&gt;Sort out your conflict policy before you need it. Reassigning &lt;code&gt;user_id&lt;/code&gt; is clean when the target account is brand new. If someone can sign into an &lt;em&gt;existing&lt;/em&gt; account while holding guest data, decide whether you merge, keep both, or refuse. Don't find that out in prod.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;UPDATE&lt;/code&gt; statements.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>authentication</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
