This guide covers authentication patterns for One apps, from simple session management to full OAuth flows. One’s unique combination of middleware, loaders, and cross-platform support gives you flexibility in how you protect routes.
There are three main approaches to authentication:
| Approach | Best For | Complexity |
|---|---|---|
| Self-hosted libraries | Most apps, full control, own database | Low-Medium |
| Hosted providers | Fastest setup, managed infrastructure | Low |
| Custom auth | Specific requirements, full control | High |
Self-hosted libraries like Better Auth or Auth.js run on your own server with your own database. You get full control over user data while the library handles the complexity of OAuth flows, session management, and security best practices. This is our recommended approach.
Hosted providers like Clerk or Supabase Auth manage everything for you including infrastructure. Fastest to set up but user data lives on their servers.
Custom auth means implementing everything yourself - password hashing, session management, token validation, OAuth flows. Only recommended if you have very specific requirements.
The foundation of any auth system is a session context that tracks auth state across your app.
features/auth/ctx.tsx
import { createContext, useContext, useState, useEffect, type PropsWithChildren } from 'react'
import { storage } from './storage'
type AuthContextType = {
signIn: (token: string) => void
signOut: () => void
session: string | null
isLoading: boolean
}
const AuthContext = createContext<AuthContextType | null>(null)
export function useSession() {
const ctx = useContext(AuthContext)
if (!ctx) {
throw new Error('useSession must be used within SessionProvider')
}
return ctx
}
export function SessionProvider({ children }: PropsWithChildren) {
const [session, setSession] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
storage.get('session').then((token) => {
setSession(token)
setIsLoading(false)
})
}, [])
const signIn = (token: string) => {
storage.set('session', token)
setSession(token)
}
const signOut = () => {
storage.remove('session')
setSession(null)
}
return (
<AuthContext.Provider value={{ signIn, signOut, session, isLoading }}>
{children}
</AuthContext.Provider>
)
}
For token storage, use a cross-platform storage library. Popular options:
Create a platform-specific abstraction using One’s file extensions:
Terminal
features/auth/
├── storage.ts # Shared interface
├── storage.web.ts # localStorage or MMKV for web
└── storage.native.ts # MMKV or expo-secure-store for native
This keeps your auth context clean while each platform uses appropriate secure storage.
There are two approaches to protecting routes in One:
| Approach | Best For |
|---|---|
<Protected /> | Routes that shouldn’t exist when unauthorized (hidden tabs, admin-only pages) |
| Redirect guard | Routes that redirect to login but support returning after auth |
<Protected />The <Protected /> component completely filters out routes when its guard prop is false. This is ideal when routes should be invisible to unauthorized users:
app/(app)/_layout.tsx
import { Stack, Protected } from 'one'
import { useSession } from '~/features/auth/ctx'
export default function AppLayout() {
const { session, isLoading } = useSession()
if (isLoading) return null
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Home' }} />
<Protected guard={!!session}>
<Stack.Screen name="dashboard" options={{ title: 'Dashboard' }} />
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
</Protected>
<Protected guard={session?.role === 'admin'}>
<Stack.Screen name="admin" options={{ title: 'Admin' }} />
</Protected>
</Stack>
)
}
When session is null, attempts to navigate to /dashboard or /settings are blocked entirely - the routes don’t exist in the navigation state.
Protect routes using a layout guard that checks auth state and redirects unauthorized users. This approach allows deep linking to protected pages - users are redirected to login, then can return to their original destination.
_layout.tsx
Root layout with SessionProvider
login.tsx
Public - login page
(app)
_layout.tsx
Auth guard - redirects if not logged in
index.tsx
Protected home
profile.tsx
Protected profile
app/_layout.tsx
import { Slot } from 'one'
import { SessionProvider } from '~/features/auth/ctx'
export default function RootLayout() {
return (
<SessionProvider>
<Slot />
</SessionProvider>
)
}
app/(app)/_layout.tsx
import { Redirect, Slot, useFocusEffect, useRouter } from 'one'
import { useSession } from '~/features/auth/ctx'
export default function AppLayout() {
const { session, isLoading } = useSession()
const router = useRouter()
// Re-check auth when screen focuses (important for native)
useFocusEffect(() => {
if (!isLoading && !session) {
router.replace('/login')
}
}, [isLoading, session])
if (isLoading) {
return null
}
if (!session) {
return <Redirect href="/login" />
}
return <Slot />
}
The useFocusEffect ensures auth is re-checked when returning to a screen on native, where screens may stay mounted in memory.
Keep authenticated users away from auth pages:
app/login.tsx
import { Redirect, useRouter } from 'one'
import { useSession } from '~/features/auth/ctx'
export default function LoginPage() {
const { session } = useSession()
const router = useRouter()
if (session) {
return <Redirect href="/" />
}
const handleLogin = async () => {
// ... your login logic
router.replace('/')
}
return (
<Button onPress={handleLogin}>
Sign In
</Button>
)
}
One’s middleware runs on the server before your route, making it ideal for auth checks.
app/api/_middleware.ts
import { createMiddleware } from 'one'
import { validateToken } from '~/features/auth/server'
export default createMiddleware(async ({ request, next, context }) => {
const token = request.headers.get('authorization')?.replace('Bearer ', '')
if (!token) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const user = await validateToken(token)
context.user = user
} catch {
return Response.json({ error: 'Invalid token' }, { status: 401 })
}
return await next()
})
Check auth in loaders for SSR pages:
app/profile.tsx
import { redirect, useLoader } from 'one'
import { validateSession } from '~/features/auth/server'
export async function loader({ request }) {
const session = await validateSession(request)
if (!session) {
throw redirect('/login')
}
const user = await getUser(session.userId)
return { user }
}
export default function ProfilePage() {
const { user } = useLoader()
return (
<View>
<Text>Welcome, {user.name}</Text>
</View>
)
}
features/auth/server.ts
import { betterAuth } from 'better-auth'
import { jwt, bearer } from 'better-auth/plugins'
export const auth = betterAuth({
database: process.env.DATABASE_URL,
plugins: [
jwt({ expirationTime: '7d' }),
bearer(),
],
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
})
app/api/auth/[...rest]+api.ts
import { auth } from '~/features/auth/server'
export const GET = (req: Request) => auth.handler(req)
export const POST = (req: Request) => auth.handler(req)
app/login.tsx
import { authClient } from '~/features/auth/client'
export default function LoginPage() {
const handleGitHubLogin = () => {
authClient.signIn.social({ provider: 'github' })
}
return (
<Button onPress={handleGitHubLogin}>
Sign in with GitHub
</Button>
)
}
features/auth/useLogout.ts
import { useRouter } from 'one'
import { useSession } from './ctx'
import { authClient } from './client'
export function useLogout() {
const router = useRouter()
const { signOut } = useSession()
const logout = async () => {
try {
await authClient.signOut()
signOut()
router.replace('/login')
} catch (error) {
console.error('Logout failed:', error)
}
}
return { logout }
}
See the one-fullstack example for a complete Supabase authentication implementation with OAuth, protected routes, and user management.
Edit this page on GitHub.