Secure React Native App Authentication with PocketBase: A Step-by-Step Guide
What is PocketBase?
PocketBase is an awesome little backend as a service (BaaS) that runs on Go. Its feature set includes an embedded (SQLite) database with real-time subscriptions and built-in auth management.
Client-side SDKs are available for both Dart and JavaScript, which makes it incredibly easy to get up and running within a React Native or Flutter mobile project.
Project Setup
In this tutorial, we're going to build a simple app that includes a login screen, an account creation screen, and a few screens that require the user to be authenticated to view.
Let's start by setting up a minimal React Native Expo application using the expo-router "tabs" template.
npx create-expo-app@latest
npx create-expo-app --templates
We'll also need some other packages to make all of this work. Let's install pocketbase
, and @react-native-async-storage/async-storage
. We'll need AsyncStorage to persist sessions within React Native. This is referenced here in the JavaScript SDK docs.
Since we're using Expo, use this command to install the packages:
npx expo install pocketbase @react-native-async-storage/async-storage
Create a PocketBase context provider and hook
Use a context provider and a custom hook to initialize our PocketBase instance within the app. Putting this in a context and provider will allow us to easily access our PocketBase instance anywhere in the app.
Create a folder at the root of the project and name it context
. Within that folder, create a file named pocketbase.jsx
.
In pocketbase.jsx
:
// pocketbase.js
import React, { createContext, useContext, useState, useEffect } from 'react';
import PocketBase, { AsyncAuthStore } from 'pocketbase';
import AsyncStorage from '@react-native-async-storage/async-storage';
const PocketBaseContext = createContext();
export const usePocketBase = () => useContext(PocketBaseContext);
export const PocketBaseProvider = ({ children }) => {
const [pb, setPb] = useState();
useEffect(() => {
const initializePocketBase = async () => {
// This is where our auth session will be stored. It's PocketBase magic.
const store = new AsyncAuthStore({
save: async (serialized) => AsyncStorage.setItem('pb_auth', serialized),
initial: await AsyncStorage.getItem('pb_auth'),
clear: async () => AsyncStorage.removeItem('pb_auth'),
});
const pbInstance = new PocketBase('<your-pocketbase-url>', store);
setPb(pbInstance);
};
initializePocketBase();
}, []);
return (
<PocketBaseContext.Provider value={{ pb }}>
{children}
</PocketBaseContext.Provider>
);
};
You'll notice that when we initialize PocketBase, we instantiate a new AsyncAuthStore
. This is responsible for storing our user's session data in AsyncStorage
. We then pass that store
to the new Pocketbase
instance.
In app/_layout.jsx
, we'll wrap the RootLayout
with the provider we just created.
// app/_layout.jsx
import FontAwesome from '@expo/vector-icons/FontAwesome';
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import React, { useEffect } from 'react';
import { PocketBaseProvider } from '@/context/pocketbase';
import { useColorScheme } from '@/components/useColorScheme';
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from 'expo-router';
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(tabs)',
};
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
...FontAwesome.font,
});
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
return (
<PocketBaseProvider>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name='(tabs)' options={{ headerShown: false }} />
<Stack.Screen name='modal' options={{ presentation: 'modal' }} />
</Stack>
</ThemeProvider>
</PocketBaseProvider>
);
}
Create an auth context provider and hook
We want to access the user's authentication status from anywhere in the app. Let's create an AuthContext
and a useAuth
hook that will export our user's authentication status and the methods needed to sign in, create an account, and sign out.
Create a file named auth.jsx
in the context
folder:
// context/auth.jsx
import { useSegments, useRouter, useNavigationContainerRef } from 'expo-router';
import { useState, useEffect, createContext, useContext } from 'react';
import { usePocketBase } from './pocketbase';
const AuthContext = createContext({});
// This hook can be used to access the user info.
export function useAuth() {
return useContext(AuthContext);
}
function useProtectedRoute(user, isInitialized) {
const router = useRouter();
const segments = useSegments();
// Check that navigation is all good
const [isNavigationReady, setIsNavigationReady] = useState(false);
const rootNavRef = useNavigationContainerRef();
// Set ups a listener to check and see if the navigator is ready.
useEffect(() => {
const unsubscribe = rootNavRef?.addListener('state', (event) => {
setIsNavigationReady(true);
});
return function cleanup() {
if (unsubscribe) {
unsubscribe();
}
};
}, [rootNavRef.current]);
useEffect(() => {
// Navigation isn't set up. Do nothing.
if (!isNavigationReady) return;
const inAuthGroup = segments[0] === '(auth)';
if (!isInitialized) return;
if (
// If the user is not signed in and the initial segment is not anything in the auth group.
!user &&
!inAuthGroup
) {
// Redirect to the sign-in page.
router.replace('/(auth)/login');
} else if (user && inAuthGroup) {
// Redirect away from the sign-in page.
router.replace('/(tabs)');
}
}, [user, segments, isNavigationReady, isInitialized]);
}
export function AuthProvider({ children }) {
const { pb } = usePocketBase();
const [isInitialized, setIsInitialized] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [user, setUser] = useState(null);
useEffect(() => {
const checkAuthStatus = async () => {
if (pb) {
// Assuming your PocketBase setup includes some method to check auth status
const isLoggedIn = pb.authStore.isValid;
setIsLoggedIn(isLoggedIn);
setUser(isLoggedIn ? pb.authStore.model : null);
setIsInitialized(true);
}
};
checkAuthStatus();
}, [pb]);
const appSignIn = async (email, password) => {
if (!pb) return { error: 'PocketBase not initialized' };
try {
const resp = await pb
?.collection('users')
.authWithPassword(email, password);
setUser(pb?.authStore.isValid ? pb.authStore.model : null);
setIsLoggedIn(pb?.authStore.isValid ?? false);
return { user: resp?.record };
} catch (e) {
return { error: e };
}
};
const appSignOut = async () => {
if (!pb) return { error: 'PocketBase not initialized' };
try {
await pb?.authStore.clear();
setUser(null);
setIsLoggedIn(false);
return { user: null };
} catch (e) {
return { error: e };
}
};
const createAccount = async ({ email, password, passwordConfirm, name }) => {
if (!pb) return { error: 'PocketBase not initialized' };
try {
const resp = await pb.collection('users').create({
email,
password,
passwordConfirm,
name: name ?? '',
});
return { user: resp };
} catch (e) {
return { error: e.response };
}
};
useProtectedRoute(user, isInitialized);
return (
<AuthContext.Provider
value={{
signIn: (email, password) => appSignIn(email, password),
signOut: () => appSignOut(),
createAccount: ({ email, password, passwordConfirm, name }) =>
createAccount({ email, password, passwordConfirm, name }),
isLoggedIn,
isInitialized,
user,
}}
>
{children}
</AuthContext.Provider>
);
}
A lot is going on in there! I commented on some of the items that may not be as clear as others. One of the things I struggled with was the navigation state not being initialized before checking for the auth state of the user. After doing some digging in the expo router docs, I found that useNavigationContainerRef()
is useful for checking to see if the <NavigationContainer />
has mounted.
The high level of the flow in this context is:
Wait for the navigation to mount
Check and see if we're in the
(auth)
segment. (This is the part of the app that requires no auth).If we're not logged in and not in the (auth) segment, redirect to
login
.If we're logged in and we're in the (auth) segment, redirect to
home
.Export our user, logged-in state, and auth methods.
useProtectedRoute
performs the check for steps 2-4. This check will be performed on every render.Wrap the root _layout.jsx
with the AuthProvider
// _layout.jsx
//.....other code
function RootLayoutNav() {
const colorScheme = useColorScheme();
return (
<PocketBaseProvider>
<AuthProvider>
<ThemeProvider
value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}
>
<Stack>
<Stack.Screen name='(tabs)' options={{ headerShown: false }} />
<Stack.Screen name='modal' options={{ presentation: 'modal' }} />
</Stack>
</ThemeProvider>
</AuthProvider>
</PocketBaseProvider>
);
}
Set up our app structure and root index.jsx
As described above, we need to separate our app into "segments". One segment will be screens that don't require auth - log in, create an account, reset your password, etc. The other segment will be "protected routes". Or, screens that require us to be logged in.
Things are ok right now, but there's a little "flash" of the home screen when we start the app.
We can fix this by including a index.jsx
file at the root of our app that uses the isInitialized
value exported from the auth context.
// app/index.jsx
import { View, ActivityIndicator } from 'react-native';
import { useAuth } from '@/context/auth';
import { useRootNavigationState, useRouter, useSegments } from 'expo-router';
import { useEffect } from 'react';
export default function Index() {
const { isInitialized, isLoggedIn } = useAuth();
const router = useRouter();
const segments = useSegments();
const navigationState = useRootNavigationState();
useEffect(() => {
if (!isInitialized || !navigationState?.key) return;
const inAuthGroup = segments[0] === '(auth)';
if (
// If the user is not signed in and the initial segment is not anything
// segment is not anything in the auth group.
!isLoggedIn &&
!inAuthGroup
) {
// Redirect to the login page.
router.replace('/(auth)/login');
} else if (isLoggedIn) {
// go to tabs root.
router.replace('/(tabs)');
}
}, [segments, navigationState?.key, isInitialized]);
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
{!navigationState?.key ? <ActivityIndicator /> : <></>}
</View>
);
}
A lot of this should look familiar. It follows the same pattern as the AuthContext
but performs the checks on app launch. Screen-flash eliminated!
That's it!
I won't cover the login and account creation UI. You can find all of the code for this article on GitHub HERE!
If you loved this article, please let me know! Press the like button below, send me a DM on Twitter @ctrlaltideate, or buy me a coffee ๐ค.
References
Subscribe to my newsletter
Read articles from Richard Westmoreland directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Richard Westmoreland
Richard Westmoreland
I'm a developer who grew up in Houston, TX. I've done a lot of things in my life and have finally found that software and development is truly what makes me happy. I've been a musician, a teacher, a flight attendant, and airline pilot, and now a full stack software engineer. I get to learn about new tech every day. And I'm here to share it all with you.