Storyie is a diary app that runs on the web (Next.js) and mobile (Expo). Both surfaces authenticate against the same Supabase project. "Supabase handles auth" is technically true, but wiring it correctly across Server Components, Server Actions, Middleware, and a React Native app with offline requirements surfaces a surprising amount of detail. This post covers the decisions that weren't obvious and the bugs we hit along the way.
TL;DR
- Use three distinct Supabase client shapes on the web: read-only for Server Components, read-write for Server Actions and Route Handlers, and a
globalThissingleton for the browser. - The
setAllcookie handler in non-read-only clients needs atry-catch— Supabase's async notification system fires after the request context closes. - In Middleware, use
getSession()(local, no network) for public routes andgetUser()(server-verified) for protected routes. Mixing them up is either a performance regression or a security gap. - Mobile auth uses native Google/Apple SDKs plus
signInWithIdToken— no redirect, no callback, no code exchange. - API routes need a helper that checks for a Bearer token first and falls back to cookies. Anything else breaks mobile clients.
- Client-side
getUser()without a cache layer will produce an Auth API request storm. Add a 30-second TTL cache with request deduplication.
Concern | Web approach | Mobile approach |
|---|---|---|
Sign-in flow | Server Action → OAuth redirect → callback Route Handler | Native SDK → |
Session storage | HttpOnly cookies set by Supabase SSR |
|
Route protection | Middleware ( | App-level auth state check |
API auth | Cookie from browser session | Bearer token in Authorization header |
Offline resilience | Not required | SecureStore read before Supabase call |
Three Supabase client shapes for Next.js
Next.js Server Components, Server Actions, and browser components each have different relationships to cookies. Using the same client for all three is the most common source of Supabase auth bugs in Next.js projects.
1. Server Components — read-only
Server Components can call cookies() from next/headers but cannot write to it. Any attempt to set() a cookie inside a render throws. The right client for this context nops the write side entirely:
// React.cache() shares a single instance within one request
export const createReadOnlyServerClient = cache(async () => {
const cookieStore = await cookies();
return createServerClient({
cookies: {
getAll: () => cookieStore.getAll(),
setAll: () => {}, // intentionally empty
},
});
});The empty setAll is intentional. Supabase's GoTrueClient internally calls _notifyAllSubscribers, which may try to refresh a token and write updated cookies as a side effect. Silently discarding those writes in a Server Component is safe — the token refresh will happen in the next request context where writes are allowed.
2. Server Actions and Route Handlers — read-write
Inside a Server Action or Route Handler, cookies() is fully writable. This client needs a real setAll:
export async function createClient() {
const cookieStore = await cookies();
return createServerClient({
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
try {
for (const { name, value, options } of cookiesToSet) {
cookieStore.set(name, value, options);
}
} catch {
// Supabase's async notifications may fire after the request context
// has closed. Swallowing the error here is safe — the session is
// already established by this point.
}
},
},
});
}The try-catch is the non-obvious part. Supabase fires _notifyAllSubscribers via a microtask queue. If the notification resolves after the Next.js response has been sent, cookieStore.set() throws because there are no headers left to write to. Catching and discarding that error keeps things quiet — by the time it fires, the session cookie is already set.
3. Browser — singleton via globalThis
Creating multiple GoTrueClient instances in the browser produces a warning and causes subtle state divergence. The fix is a module-level singleton via globalThis, which survives Next.js HMR without creating new instances:
const globalForSupabase = globalThis as unknown as {
supabaseInstance: TypedSupabaseClient | undefined;
};
export function createClientSupabase(): TypedSupabaseClient {
if (!globalForSupabase.supabaseInstance) {
globalForSupabase.supabaseInstance = createBrowserClient<Database>(
env.NEXT_PUBLIC_SUPABASE_URL,
env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
}
return globalForSupabase.supabaseInstance;
}OAuth callback: cookie buffering
The OAuth callback Route Handler is where Supabase exchanges an auth code for a session and sets the session cookies. The problem is that setAll may be called more than once during the exchange, and intermediate writes can conflict if you apply them directly to the response as they arrive.
Our callback client buffers all cookie writes and applies them to the NextResponse at the end:
const cookiesToSet: CookieToSet[] = [];
const supabase = createServerClient({
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookies) => {
for (const cookie of cookies) {
const existingIndex = cookiesToSet.findIndex(c => c.name === cookie.name);
if (existingIndex >= 0) {
cookiesToSet[existingIndex] = cookie; // last write wins
} else {
cookiesToSet.push(cookie);
}
}
},
},
});After the code exchange completes, we iterate cookiesToSet and apply them to the response in one pass. Last write wins on duplicate names, which matches browser cookie semantics.
First-login detection. We show a welcome message when a user signs in for the first time. The signal we use is comparing created_at and last_sign_in_at on the user object:
const createdAt = new Date(data.user.created_at).getTime();
const lastSignIn = new Date(data.user.last_sign_in_at).getTime();
const isFirstLogin = Math.abs(lastSignIn - createdAt) < 60000; // within 1 minuteIt is not perfect — a user who signs in immediately after account creation via another provider would get the welcome screen again — but it is close enough for a heuristic that does not need a database write.
Middleware: two different strategies for two route types
proxy.ts runs on every request. The design question is: how much Auth work does it do per request?
getSession() validates the JWT locally without hitting the Supabase Auth server. It is fast but cannot detect a revoked token — someone who crafted a valid-looking JWT would pass. getUser() sends the token to the Auth server for cryptographic verification. It is slow but definitive.
Storyie uses both, depending on the route:
if (isPublicRoute(pathname)) {
// Local token refresh only — no Auth server round-trip
const { supabaseResponse } = await refreshSessionOnly(req);
return supabaseResponse;
}
// Protected route: verify the JWT server-side
const { supabaseResponse, user } = await createClientAndRefreshSession(req);
if (!user) {
return createLoginRedirect(req, pathname);
}Public pages benefit from the lower latency of the local check. Protected pages pay for the round-trip because the security guarantee is worth it — a spoofed cookie needs to actually hit the Auth server before it fails, not just fail a local expiry check.
Mobile: native SDK plus signInWithIdToken
On mobile, Google and Apple auth flows are native SDK calls. There is no browser redirect, no callback URL, and no auth code to exchange. The native SDK returns an ID token that we pass directly to Supabase:
// Google
const userInfo = await GoogleSignin.signIn();
const { data, error } = await supabase.auth.signInWithIdToken({
provider: "google",
token: userInfo.data.idToken,
});
// Apple
const credential = await AppleAuthentication.signInAsync({ /* ... */ });
const { data, error } = await supabase.auth.signInWithIdToken({
provider: "apple",
token: credential.identityToken,
});Supabase verifies the token against the provider's JWKS and issues a session. The flow completes without leaving the app.
Session persistence via SecureStore
Mobile apps have to survive process termination. We persist the Supabase session to expo-secure-store and read it back on launch before hitting Supabase:
async function saveSessionToSecureStore(session: Session) {
await SecureStore.setItemAsync(USER_SESSION_KEY, JSON.stringify(session.user));
await SecureStore.setItemAsync(TOKEN_KEY, session.token);
}On startup, SecureStore is checked first. This gives the app something to show immediately, even before the Supabase call returns — and if the device is offline, it means the user still sees their profile instead of an error screen.
We also register an onAuthStateChange listener for the TOKEN_REFRESHED event to keep SecureStore in sync whenever Supabase silently refreshes the token in the background.
Dual auth in API routes
Some Next.js API routes are consumed by both the web client (which sends a session cookie) and the mobile app (which sends an Authorization: Bearer <token> header). A single helper abstracts the two cases:
export async function authenticateApiRequest(request: NextRequest) {
const supabase = await createClient();
// Bearer token takes priority (mobile clients)
const bearerToken = extractBearerToken(request);
if (bearerToken) {
const { data, error } = await supabase.auth.getUser(bearerToken);
return { user: data.user, error };
}
// Fall back to cookie-based session (web clients)
const { data, error } = await supabase.auth.getUser();
return { user: data.user, error };
}Bearer-first matters because a mobile user who has ever visited the web might have a session cookie from that visit. Without the priority ordering, a mobile request that happens to carry a stale web cookie gets authenticated against the wrong session.
Fixing the client-side getUser() storm
During development we noticed the Auth API was being called 4+ times per second while navigating the dashboard. The cause: several independent client components each called supabase.auth.getUser() without any coordination.
The fix is a module-level cache with request deduplication:
let pendingRequest: Promise<User | null> | null = null;
let cachedUser: User | null = null;
let cacheTimestamp = 0;
export async function getCachedUser(): Promise<User | null> {
// Return cached value if fresh
if (cachedUser && Date.now() - cacheTimestamp < 30_000) {
return cachedUser;
}
// Attach to in-flight request instead of starting a new one
if (pendingRequest) return pendingRequest;
pendingRequest = (async () => {
try {
const supabase = createClientSupabase();
const { data } = await supabase.auth.getUser();
cachedUser = data.user;
cacheTimestamp = Date.now();
return data.user;
} finally {
pendingRequest = null;
}
})();
return pendingRequest;
}The 30-second TTL means at most two Auth API calls per minute regardless of how many components are mounted. The deduplication means a burst of simultaneous renders produces exactly one in-flight request, not N.
A SessionRefreshProvider wraps the app and handles the edge cases: a visibilitychange listener triggers a refresh when the user switches back to the tab, and a 10-minute interval covers sessions that go stale while the tab is in the foreground.
Mobile-to-web session handoff
The iOS app has a "view on web" feature that opens a URL in Safari with the user already signed in. The mechanism is a /api/auth/mobile-transfer endpoint:
- The mobile app appends its access token as a query parameter when opening the web URL.
- The Route Handler extracts the token, calls
supabase.auth.getUser(token)to verify it, then callssetSessionwith the access and refresh tokens to establish a web session. - The Route Handler sets the session cookies and redirects to the target URL.
It is not a perfect handoff — the access token's expiry window means a slow network can produce a race — but for the use case (tapping a button and being landed on the web with a session) it works reliably in practice.
Key takeaways
- Three client shapes, not one. Server Component (read-only
setAll), Server Action/Route Handler (read-write withtry-catch), browser (singleton viaglobalThis). Getting any of these wrong produces auth bugs that are hard to reproduce because they depend on async timing. setAllalways needs atry-catch. Supabase's notification system fires asynchronously and will sometimes outlive the request context. The error is harmless, but it will cause noise in your logs if you don't catch it.getSession()vsgetUser()in Middleware is a performance-security tradeoff, not a style choice. UsegetSession()on public routes,getUser()on protected ones.- Mobile auth is simpler than web auth. Native SDKs give you an ID token directly.
signInWithIdTokenis the only Supabase call you need. No redirect, no callback handler, no cookie plumbing. - Client-side
getUser()without caching is a bug waiting to happen. A 30-second TTL plus request deduplication brings Auth API usage from a storm to a trickle. - Shared API routes need a Bearer-first auth helper. Cookie fallback handles the web case; Bearer token handles mobile. Priority ordering prevents stale cookies from interfering with mobile sessions.
Related Posts
- Building a Monorepo with pnpm and TypeScript — the workspace conventions that let web and mobile share code without leaking platform-specific dependencies
- Building a Cross-Platform Mobile App with Expo — the broader Expo architecture that the auth setup lives inside
- Cross-platform Lexical with
use dom: monorepo gains and the bridges you still own — the same cross-platform thinking applied to the editor layer
Try Storyie
Storyie is available on the web, iOS, and Android beta. Sign in once with Google or Apple and your diaries are available on every surface — the auth plumbing described here is what makes that work.