Migrating Authentication Without Rewriting Every Caller

Storyie Engineering Team
7 min read

We swapped Supabase Auth for Better Auth on the web without touching 56 call sites, by mapping the new session onto the old user shape behind one cached function. Here is the compatibility shim, why we chose it, and where it lies.

We moved Storyie's web app from Supabase Auth to Better Auth. The interesting part of that migration was not wiring up the new provider — Better Auth's Drizzle adapter and social providers came together in an afternoon. The interesting part was everything that read the current user.

By the time we migrated, 56 files in apps/web called our current-user accessor, and many of them reached into fields with names Supabase had chosen: user.user_metadata.full_name, user.user_metadata.avatar_url, user.app_metadata.provider. Some were even annotated with the User type from @supabase/supabase-js. A naïve provider swap means opening all 56 files, learning Better Auth's user shape, and rewriting each access — a large, risky diff to land in one go, with a real chance of a missed call site slipping an authenticated page into a broken state.

We didn't do that. We changed one file and left the other 55 untouched. This post is about the compatibility shim that made that possible, why we accepted it, and the one place it deliberately lies.

TL;DR

  • A provider swap's blast radius is every caller that reads the user, not the auth wiring. In our web app that was 56 call sites.
  • We funneled all of them through one accessor, getCachedUser(), that returns a CompatUser — Better Auth's session.user mapped back onto the legacy Supabase-shaped object callers already expected.
  • getCachedUser() is wrapped in React.cache(), so all 56 potential calls in a single request resolve the session once.
  • It fails closed to null (logged out) and logs the real error server-side, so a bad secret during cutover degrades the page instead of crashing it.
  • The shim can't invent data the new session lacks: app_metadata.provider is stubbed as "oauth", and the one caller that needs the real provider queries the account table directly.
  • The shim is tracked debt: it began as a mimic of the @supabase/supabase-js User type and, once that package was removed, became our own CompatUser type — with caller migration tracked as a follow-up.

A provider swap's blast radius is the callers, not the wiring

Standing up Better Auth is a contained change. Here is the whole server instance — one betterAuth() call with the Drizzle adapter, social providers, and a few plugins:

// apps/web/lib/auth/server.ts (excerpt)
export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "sqlite",
    schema: {
      user: authUsers,
      session: authSessions,
      account: authAccounts,
      verification: authVerifications,
    },
  }),
  secret: process.env.BETTER_AUTH_SECRET ?? process.env.AUTH_SECRET,
  socialProviders: {
    google: resolveGoogleCredentials(),
    apple: async () => ({ /* ... short-lived ES256 JWT ... */ }),
  },
  plugins: [expo(), anonymous({ /* ... */ }), nextCookies()],
});

The cost is not here. The cost is that every page, layout, and Server Action that shows "who am I" was written against Supabase's User shape. A quick grep set the scope:

$ grep -rl "getCachedUser" apps/web --include="*.ts" --include="*.tsx" | wc -l
56

Fifty-six files, all reaching through the same accessor, and behind it callers doing things like:

// apps/web/app/(auth)/account-name-setup/page.tsx (excerpt)
const fullName =
  typeof user.user_metadata?.full_name === "string" ? user.user_metadata.full_name : undefined;
const suggestedSlug = profile?.slug ?? suggestSlug(fullName ?? user.email);

Better Auth does not have user_metadata. It keeps the display name at user.name and the avatar at user.image. Rewriting the callers means the migration is only "done" when the last of 56 files is correct — and TypeScript won't fully protect you, because the legacy shape used loosely-typed [key: string]: unknown metadata bags. We wanted a change we could reason about, land, and roll back as a unit. That points at one place, not fifty-six.

Map the new session onto the shape callers already expect

That one place is getCachedUser(). Its job is to call Better Auth, then translate the result into a CompatUser whose field names match what callers already read:

// apps/web/lib/auth/session.ts (excerpt)
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) return null;
const u = session.user;

return {
  // Core identity
  id: u.id,
  email: u.email ?? undefined,

  // Supabase compat stubs (structural match for `user: User` call sites)
  aud: "authenticated",
  created_at: u.createdAt instanceof Date ? u.createdAt.toISOString() : String(u.createdAt),

  // Metadata mapping: Better Auth keeps social name/image at the top level
  user_metadata: {
    full_name: u.name ?? undefined,
    avatar_url: u.image ?? undefined,
    // Some providers (Google) surface the avatar as "picture"
    picture: u.image ?? undefined,
  },

  // Passthrough Better Auth native fields
  name: u.name ?? undefined,
  image: u.image,
  emailVerified: u.emailVerified,
  isAnonymous: u.isAnonymous ?? undefined,
};

Three kinds of field live in that return value, and the distinction matters:

  • Direct maps. id and email mean the same thing in both worlds — straight through.
  • Renames. Better Auth's user.name becomes user_metadata.full_name; user.image becomes both avatar_url and picture, because Google-originated code paths read the avatar as picture while others read avatar_url. Populating both means neither caller had to change.
  • Stubs. aud: "authenticated" and created_at exist only so the object still satisfies call sites annotated with the old User type. They carry no new meaning.

We also pass through Better Auth's native fields (name, image, emailVerified, isAnonymous) so that new code can read the real shape and ignore the legacy nesting entirely. The shim is bidirectional in spirit: old callers keep working, new callers don't have to adopt the legacy vocabulary.

Resolve the session once per request with React.cache()

If 56 call sites can each ask for the user, and a single page render touches a layout, a page, and a couple of Server Actions, a naïve accessor would call auth.api.getSession() several times per request — each one a cookie parse and a database adapter round-trip. So the accessor is memoized per request:

// apps/web/lib/auth/session.ts (excerpt)
export const getCachedUser = cache(async (): Promise<CompatUser | null> => {
  // ...auth.api.getSession() + mapping...
});

cache here is React's cache() from react, not a time-based cache. It deduplicates calls within the lifetime of one server request: the first getCachedUser() does the work, the rest get the memoized CompatUser. This is what makes "just funnel everything through one function" cheap rather than a performance regression — the funnel is also the dedupe point.

Fail closed to "logged out", and log why

An accessor that gates rendering has to decide what happens when the lookup itself fails — a malformed BETTER_AUTH_SECRET, an adapter error, a half-migrated cookie. The tempting default is to let the error propagate, but at the call site a thrown error is indistinguishable from "not logged in", and an uncaught throw from a shared accessor takes the whole page down. During a provider cutover, that is exactly when things fail.

So getCachedUser() catches, logs the real cause server-side, and returns null:

// apps/web/lib/auth/session.ts (excerpt)
} catch (error) {
  // A thrown error here (bad BETTER_AUTH_SECRET, adapter failure) is otherwise
  // indistinguishable from "logged out" — log it server-side to ease cutover debugging.
  console.error("[auth] getCachedUser failed:", error);
  return null;
}

The page degrades to its logged-out state instead of crashing, and the CloudWatch log still tells us the truth. The two failure modes look the same to the caller by design, but we refuse to make them look the same to us.

Where the shim lies: the provider stub

A shim can only hand out data the new provider actually gives it, and Better Auth's session payload does not include which OAuth provider the user signed in with. Rather than do an extra query on every current-user read, the shim stubs it:

// apps/web/lib/auth/session.ts (excerpt)
// app_metadata: provider info is not in the session payload from Better Auth;
// stub "oauth" as the provider — slug.ts reads this only on first-signup paths.
app_metadata: {
  provider: "oauth",
  providers: ["oauth"],
},

This is the honest rough edge. app_metadata.provider used to be a real value ("google", "apple"); now it is a placeholder. That is fine everywhere except the one place that genuinely needs the real provider — first-signup analytics tagging — which sidesteps the shim and reads the source of truth, the account table, directly:

// apps/web/actions/slug.ts (excerpt)
// Resolve the real OAuth provider from the Better Auth `account` table
// (getCachedUser's app_metadata.provider is a stub). Used only to tag
// the signup redirect.
const accountRows = await db
  .select({ providerId: authAccounts.providerId })
  // ...where account.userId = user.id...
provider = accountRows[0]?.providerId ?? "oauth";

The lesson we'd underline: a shim that stubs a field should make the stub obvious — a constant like "oauth" that could never be a real provider id — so the one caller that can't tolerate the lie is forced to notice and route around it, instead of silently shipping "oauth" into an analytics dashboard.

The shim is tracked debt, not a new layer

A compatibility layer is only healthy if it is shrinking. Two things keep ours honest.

First, its own definition already changed shape under it. CompatUser began life as a structural mimic of the User type from @supabase/supabase-js — its whole reason for the aud/created_at stubs was to satisfy callers annotated with that external type. Since then, @supabase/supabase-js has been removed from the web app entirely; no file imports it anymore. CompatUser is now our own exported type that call sites import directly, which means the stub fields exist purely to avoid touching callers, and can be deleted the moment those callers are updated.

Second, that update is tracked work, not a someday-maybe. Migrating callers off the user_metadata/app_metadata nesting and onto Better Auth's native user type is a scoped follow-up issue, so the shim has an owner and an exit rather than quietly becoming permanent infrastructure.

Takeaways

  • The expensive part of an auth migration is the read surface. Count your call sites before you plan the swap.
  • One cached accessor returning a compatibility shape lets you change providers in a single reviewable file instead of a repo-wide rewrite.
  • Memoize the accessor per request (React.cache()) so the single funnel doesn't multiply into N session lookups.
  • Fail closed to logged-out, but log the real cause — the two must look the same to callers and different to you.
  • Make stubbed fields obviously fake, and give the shim a tracked removal path so it shrinks on purpose.

If you want the provider-specific groundwork this migration built on, see our earlier write-ups on Supabase Auth across Next.js and Expo and the full auth and authorization picture — this post is the sequel about leaving that world without a big-bang rewrite.

Available on iOS & Android

Download on the App StoreGet it on Google Play