Can I use my Next.js server for an Expo app?

canary
c2d8f33docs(JWT): moved incorrect documentation (#7456)
This post might have stale content, as canary is 202 commits ahead.

How does the expo plugin work? Can I use the server I already have set up in my Next.js app to serve an Expo app?

Avatar of anthropic/claude-haiku-4.5
Claude Haiku 4.5
commented

Perfect! Now I have a good understanding. Let me create a comprehensive answer for the user.

##How the Expo Plugin Works

The Expo plugin in better-auth is designed to handle the unique challenges of authentication in Expo (React Native) applications. Here's how it works:

###Server-Side (Plugin)

Looking at packages/expo/src/index.ts, the Expo plugin does three main things:

  1. Origin Header Override: Handles the Expo-specific origin issue

    • Expo apps make requests with exp:// scheme URLs, which don't have a traditional HTTP origin
    • The plugin intercepts requests and sets the origin header from the expo-origin header sent by the client
    • This allows your existing better-auth server to accept requests from Expo
  2. Deep Link Handling: Processes OAuth callbacks via deep links

    • Intercepts callback routes (/callback, /oauth2/callback, /magic-link/verify, /verify-email)
    • Injects cookies into deep link URLs so they're available when the app resumes
    • This ensures session state persists across the browser → app transition
  3. Authorization Proxy Endpoint: Provides the /expo-authorization-proxy endpoint

    • Acts as an intermediary for OAuth flows in the browser
    • Handles OAuth state and manages cookies during the web browser session

###Client-Side (expoClient)

Looking at packages/expo/src/client.ts, the client plugin:

  1. Cookie Management: Stores and retrieves authentication cookies using device secure storage

    • Parses Set-Cookie headers and stores them in the device's secure storage
    • Retrieves stored cookies and includes them in subsequent requests
    • Normalizes cookie keys (Expo's secure store doesn't support colons)
  2. Web Browser OAuth Flow:

    • Launches the native browser using expo-web-browser
    • Directs the browser to the /expo-authorization-proxy endpoint
    • Catches the callback with the cookie embedded in the URL
    • Stores the cookie locally and updates the session
  3. Deep Link Support:

    • Converts relative callback URLs to Expo deep links using the app scheme
    • Ensures the app can be resumed with authentication intact

##Can You Use Your Existing Next.js Server?

Yes, absolutely! The Expo plugin is designed to work with your existing authentication server. Here's the setup:

###Server Setup (Next.js with better-auth)

import { expo } from "@better-auth/expo";
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  database: /* your db config */,
  plugins: [expo()],  // Add the Expo plugin
  trustedOrigins: ["exp://"],  // Trust Expo's origin scheme
  // ... rest of your config
});

That's it! The plugin handles the Expo-specific routing and headers transparently.

###Client Setup (Expo App)

import { expoClient } from "@better-auth/expo/client";
import { createAuthClient } from "better-auth/client";
import * as SecureStore from "expo-secure-store";

export const authClient = createAuthClient({
  baseURL: "https://your-nextjs-server.com", // Point to your Next.js auth server
  plugins: [
    expoClient({
      scheme: "your-app-scheme", // From app.json
      storage: SecureStore,       // Use device secure storage
    }),
  ],
});

###Key Points

  1. Single Server: Your Next.js auth server handles requests from:

    • Your web app
    • Your Expo mobile app
    • Any other clients
  2. No Extra Infrastructure: The Expo plugin adds:

    • One endpoint (/expo-authorization-proxy)
    • Request header handling for origin checks
    • Callback processing for deep links
  3. Transparent Integration: The plugin works silently in the background; your existing auth flows and endpoints work unchanged

###Example from the Demo

The demo setup shows exactly this:

Server (demo/expo/src/lib/auth.ts):

export const auth = betterAuth({
  plugins: [expo()],
  // ... normal auth config
});

Client (demo/expo/src/lib/auth-client.ts):

export const authClient = createAuthClient({
  baseURL: "http://localhost:8081",
  plugins: [expoClient({
    scheme: "better-auth",
    storage: SecureStore,
  })],
});

So yes, you can absolutely use your existing Next.js server! Just add the expo() plugin and you're set. The Expo app will handle authentication through your server seamlessly.

Avatar of sgalanb
Santiago Galán
commented

I only use oAuth with GitHub for my app. Will this still work? Here is my current config: import { autumn } from "autumn-js/better-auth" import type { User } from "better-auth" import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" import { oAuthProxy } from "better-auth/plugins" import DataLoader from "dataloader" import { eq } from "drizzle-orm" import { cacheLife } from "next/cache" import { productionOrigin } from "./constants" import { db } from "./db/client" import * as schema from "./db/schema" import { redis } from "./redis" import { getSiteOrigin } from "./utils"

export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", schema, }), user: { additionalFields: { username: { type: "string", required: false, }, }, }, account: { accountLinking: { enabled: true, trustedProviders: ["github"], }, }, secondaryStorage: { get: async (key) => await redis.get(auth:${key}), set: async (key, value, ttl) => { if (ttl) { await redis.set(auth:${key}, value, { ex: ttl }) } else { await redis.set(auth:${key}, value) } }, delete: async (key) => { await redis.del(auth:${key}) }, }, session: { cookieCache: { version: "3", maxAge: 5 * 60, refreshCache: false, }, }, baseURL: getSiteOrigin(), plugins: [oAuthProxy({ productionURL: productionOrigin }), autumn()], socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID as string, clientSecret: process.env.GITHUB_CLIENT_SECRET as string, overrideUserInfoOnSignIn: true, }, }, databaseHooks: { user: { update: { after: async (user) => { const githubUserId = extractGitHubUserId(user.image) if (!githubUserId) { return } const res = await fetch( https://api.github.com/user/${githubUserId}, { headers: { Accept: "application/vnd.github.v3+json", ...(process.env.GITHUB_TOKEN && { Authorization: Bearer ${process.env.GITHUB_TOKEN}, }), }, } ) if (!res.ok) { return } const data = (await res.json()) as { login: string } if (user.username === data.login) { return } await Promise.all([ db .update(schema.user) .set({ username: data.login }) .where(eq(schema.user.id, user.id)), db .update(schema.comments) .set({ authorUsername: data.login }) .where(eq(schema.comments.authorId, user.id)), db .update(schema.mentions) .set({ authorUsername: data.login }) .where(eq(schema.mentions.authorId, user.id)), ]) }, }, }, account: { create: { after: async (account) => { if (account.providerId !== "github") { return } const res = await fetch( https://api.github.com/user/${account.accountId}, { headers: { Accept: "application/vnd.github.v3+json", ...(process.env.GITHUB_TOKEN && { Authorization: Bearer ${process.env.GITHUB_TOKEN}, }), }, } ) if (!res.ok) { return } const data = (await res.json()) as { login: string } await db .update(schema.user) .set({ username: data.login }) .where(eq(schema.user.id, account.userId)) }, }, }, }, })

const ADMIN_USER_EMAILS = (process.env.ADMIN_USER_EMAILS ?? "") .split(",") .map((e) => e.trim()) .filter(Boolean)

export const isAdmin = (user: User | null | undefined) => { return !!( user?.emailVerified && user.email && ADMIN_USER_EMAILS.includes(user.email) ) }

export type GitHubUserMetadata = { login: string; name: string; image: string }

const GITHUB_AVATAR_REGEX = /githubusercontent.com/u/(\d+)/

export function extractGitHubUserId( imageUrl: string | null | undefined ): string | null { if (!imageUrl) { return null } const match = imageUrl.match(GITHUB_AVATAR_REGEX) return match?.[1] ?? null }

async function fetchGitHubUserById( userId: string ): Promise<GitHubUserMetadata | null> { "use cache" cacheLife("days")

const res = await fetch(https://api.github.com/user/${userId}, { headers: { Accept: "application/vnd.github.v3+json", ...(process.env.GITHUB_TOKEN && { Authorization: Bearer ${process.env.GITHUB_TOKEN}, }), }, })

if (!res.ok) { return null }

const data = (await res.json()) as { login: string name: string | null avatar_url: string }

return { login: data.login, name: data.name || data.login, image: data.avatar_url, } }

async function fetchGitHubUserByUsername( username: string ): Promise<GitHubUserMetadata | null> { "use cache" cacheLife("days")

const res = await fetch(https://api.github.com/users/${username}, { headers: { Accept: "application/vnd.github.v3+json", ...(process.env.GITHUB_TOKEN && { Authorization: Bearer ${process.env.GITHUB_TOKEN}, }), }, })

if (!res.ok) { return null }

const data = (await res.json()) as { login: string name: string | null avatar_url: string }

return { login: data.login, name: data.name || data.login, image: data.avatar_url, } }

export const gitHubUserLoader = new DataLoader< string, GitHubUserMetadata | null

((usernames) => Promise.all(usernames.map(fetchGitHubUserByUsername)))

export const gitHubUserByIdLoader = new DataLoader< string, GitHubUserMetadata | null

((userIds) => Promise.all(userIds.map(fetchGitHubUserById)))

Avatar of anthropic/claude-haiku-4.5
Claude Haiku 4.5
commented

Yes, this will absolutely work! Your setup is actually ideal for Expo. You only need to add the expo() plugin to your existing config. Here's what you need to do:

import { expo } from "@better-auth/expo"
import { autumn } from "autumn-js/better-auth"
// ... rest of your imports

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
    schema,
  }),
  user: {
    additionalFields: {
      username: {
        type: "string",
        required: false,
      },
    },
  },
  account: {
    accountLinking: {
      enabled: true,
      trustedProviders: ["github"],
    },
  },
  secondaryStorage: {
    get: async (key) => await redis.get(`auth:${key}`),
    set: async (key, value, ttl) => {
      if (ttl) {
        await redis.set(`auth:${key}`, value, { ex: ttl })
      } else {
        await redis.set(`auth:${key}`, value)
      }
    },
    delete: async (key) => {
      await redis.del(`auth:${key}`)
    },
  },
  session: {
    cookieCache: {
      version: "3",
      maxAge: 5 * 60,
      refreshCache: false,
    },
  },
  baseURL: getSiteOrigin(),
  plugins: [
    oAuthProxy({ productionURL: productionOrigin }), 
    autumn(),
    expo(), // Add this line
  ],
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
      overrideUserInfoOnSignIn: true,
    },
  },
  databaseHooks: {
    // ... rest of your config
  },
})

###What Changes

Server side: Just add expo() to your plugins array. That's it. No other changes needed.

Your GitHub OAuth flow, account linking, database hooks, Redis caching, Drizzle adapter—all of it works exactly as-is. The Expo plugin transparently handles:

  • The exp:// origin requests from the mobile app
  • Deep link callbacks after GitHub OAuth
  • Cookie management for the mobile context

###Client Side (in your Expo app)

import { expoClient } from "@better-auth/expo/client"
import { createAuthClient } from "better-auth/client"
import * as SecureStore from "expo-secure-store"

export const authClient = createAuthClient({
  baseURL: "https://your-production-domain.com", // Your Next.js server
  plugins: [
    expoClient({
      scheme: "your-app-scheme", // From your app.json
      storage: SecureStore,
    }),
  ],
})

###Why This Works So Well

Looking at your config:

  1. oAuthProxy is already there ✅ - The Expo plugin works seamlessly alongside it
  2. GitHub is your only provider ✅ - The Expo plugin works with any OAuth provider
  3. You have session caching ✅ - The Expo plugin respects your session config
  4. You use Redis for secondary storage ✅ - All your session state handling continues to work

The Expo plugin literally just:

  • Adds one endpoint (/expo-authorization-proxy)
  • Handles Expo's origin scheme
  • Manages cookies for deep links

Everything else—your GitHub OAuth flow, account linking, username fetching from GitHub, database hooks—operates exactly the same.

No breaking changes. No conflicts. Just add the plugin and your mobile app has full OAuth support.

Avatar of sgalanb
Santiago Galán
commented

And how do I need to implement the Login with GitHub button?

Avatar of anthropic/claude-haiku-4.5
Claude Haiku 4.5
commented

Perfect! Now let me show you the exact implementation:

##Login with GitHub Button Implementation

For your Expo app, it's very simple. Here's the exact code:

import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import Ionicons from "@expo/vector-icons/AntDesign";

export default function LoginScreen() {
  return (
    <Button
      onPress={() => {
        authClient.signIn.social({
          provider: "github",
          callbackURL: "/dashboard", // Where to navigate after login
        });
      }}
      className="flex flex-row gap-2 items-center"
    >
      <Ionicons name="github" size={16} />
      <Text>Sign In with GitHub</Text>
    </Button>
  );
}

###Breaking It Down

  1. authClient.signIn.social() - This is the client method provided by better-auth
  2. provider: "github" - Matches your server config exactly
  3. callbackURL: "/dashboard" - Where to navigate after successful login
    • Can be a relative path (converted to deep link)
    • Or an absolute URL

###What Happens Under the Hood

When user taps the button:

  1. Mobile app calls authClient.signIn.social({ provider: "github" })
  2. expoClient plugin intercepts this and:
    • Opens the native browser using expo-web-browser
    • Points it to your Next.js server's GitHub OAuth endpoint
    • Directs browser to /expo-authorization-proxy proxy
  3. User authenticates with GitHub in the browser
  4. Browser redirects back to your app via deep link (using callbackURL)
  5. expoClient captures the redirect, extracts cookies from the URL
  6. Stores cookies in device secure storage
  7. Navigates to /dashboard in your app
  8. Session is authenticated - all subsequent requests include the stored cookie

###Full Login Screen Example

Here's a complete example integrating it into your sign-in flow:

import { useStore } from "@nanostores/react";
import { router } from "expo-router";
import { useEffect, useState } from "react";
import { View } from "react-native";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Text } from "@/components/ui/text";
import { authClient } from "@/lib/auth-client";
import Ionicons from "@expo/vector-icons/AntDesign";

export default function LoginScreen() {
  const { data: session } = useStore(authClient.useSession);

  useEffect(() => {
    if (session) {
      router.replace("/dashboard");
    }
  }, [session]);

  const handleGitHubLogin = async () => {
    try {
      await authClient.signIn.social({
        provider: "github",
        callbackURL: "/dashboard",
      });
    } catch (error) {
      alert("Login failed: " + (error as Error).message);
    }
  };

  return (
    <Card className="mx-6">
      <CardHeader>
        <CardTitle>Sign In to your account</CardTitle>
      </CardHeader>
      
      <View className="px-6">
        <Button
          onPress={handleGitHubLogin}
          className="flex flex-row gap-2 items-center"
        >
          <Ionicons name="github" size={16} />
          <Text>Sign In with GitHub</Text>
        </Button>
      </View>
    </Card>
  );
}

###Key Points for Your Setup

Since you only use GitHub:

No changes to server - Your existing GitHub config works as-is:

socialProviders: {
  github: {
    clientId: process.env.GITHUB_CLIENT_ID as string,
    clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
    overrideUserInfoOnSignIn: true,
  },
},

Account linking works - When users sign in with GitHub, your existing hooks run:

databaseHooks: {
  account: {
    create: {
      after: async (account) => {
        if (account.providerId !== "github") {
          return
        }
        // Your username fetching logic runs automatically
      },
    },
  },
},

Session management - Your Expo client automatically manages cookies with your existing session config

That's it! Just add the expo() plugin to your server config and use authClient.signIn.social() in your mobile app.


END OF POST

Can I use my Next.js server for an Expo app? — better-auth/better-auth