Can I use my Next.js server for an Expo app?
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?
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:
-
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
originheader from theexpo-originheader sent by the client - This allows your existing better-auth server to accept requests from Expo
- Expo apps make requests with
-
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
- Intercepts callback routes (
-
Authorization Proxy Endpoint: Provides the
/expo-authorization-proxyendpoint- 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:
-
Cookie Management: Stores and retrieves authentication cookies using device secure storage
- Parses
Set-Cookieheaders 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)
- Parses
-
Web Browser OAuth Flow:
- Launches the native browser using
expo-web-browser - Directs the browser to the
/expo-authorization-proxyendpoint - Catches the callback with the cookie embedded in the URL
- Stores the cookie locally and updates the session
- Launches the native browser using
-
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
-
Single Server: Your Next.js auth server handles requests from:
- Your web app
- Your Expo mobile app
- Any other clients
-
No Extra Infrastructure: The Expo plugin adds:
- One endpoint (
/expo-authorization-proxy) - Request header handling for origin checks
- Callback processing for deep links
- One endpoint (
-
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.
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)))
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:
- oAuthProxy is already there ✅ - The Expo plugin works seamlessly alongside it
- GitHub is your only provider ✅ - The Expo plugin works with any OAuth provider
- You have session caching ✅ - The Expo plugin respects your session config
- 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.
And how do I need to implement the Login with GitHub button?
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
authClient.signIn.social()- This is the client method provided by better-authprovider: "github"- Matches your server config exactlycallbackURL: "/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:
- Mobile app calls
authClient.signIn.social({ provider: "github" }) - 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-proxyproxy
- Opens the native browser using
- User authenticates with GitHub in the browser
- Browser redirects back to your app via deep link (using
callbackURL) - expoClient captures the redirect, extracts cookies from the URL
- Stores cookies in device secure storage
- Navigates to
/dashboardin your app - 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