This tutorial part demonstrates how to implement robust screen protection in a React Native application using Nhost authentication. You’ll build a complete authentication system with a protected /profile screen that includes cross-device session synchronization and automatic navigation. In addition, we will see how to show conditional navigation and content based on authentication status.
This is Part 2 in the Full-Stack React Native Development with Nhost series. This part builds a foundation for authentication-protected screens that you can extend to secure any part of your application.

Full-Stack React Native Development with Nhost

Prerequisites

  • An Nhost project set up
  • Node.js 20+ installed
  • Expo CLI installed globally (npm install -g @expo/cli)
  • Basic knowledge of React Native and Expo Router

Step-by-Step Guide

1

Create a New React Native App

We’ll start by creating a fresh React Native application using Expo with TypeScript support. Expo provides fast development tools and optimized builds for modern React Native applications.
npx create-expo-app nhost-reactnative-tutorial --template blank-typescript
cd nhost-reactnative-tutorial
# we don't need the following files
rm App.tsx index.ts
2

Install Required Dependencies

Install the Nhost JavaScript SDK and AsyncStorage for client-side routing and storage. The Nhost SDK handles authentication, while AsyncStorage enables persistent session storage.
npx expo install @nhost/nhost-js @react-native-async-storage/async-storage@~2 expo-router@~6 expo-constants@~18
3

Configure Expo Router Entry Point

Update your package.json to use Expo Router as the main entry point by adding or replacing the main field (leave rest of the file unchanged):
package.json
{
  "main": "expo-router/entry"
}
4

Environment Configuration

Let’s configure our app to connect to your Nhost backend. We’ll use Expo’s app.json to store environment variables.
app.json
{
  "expo": {
    "name": "nhost-reactnative-tutorial",
    "slug": "nhost-reactnative-tutorial",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/icon.png",
    "userInterfaceStyle": "light",
    "newArchEnabled": true,
    "splash": {
      "image": "./assets/splash-icon.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "ios": {
      "supportsTablet": true
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#ffffff"
      },
      "edgeToEdgeEnabled": true,
      "predictiveBackGestureEnabled": false
    },
    "web": {
      "favicon": "./assets/favicon.png"
    },
    "extra": {
      "NHOST_REGION": <region>,
      "NHOST_SUBDOMAIN": <subdomain>
    }
  }
}
Replace <region> and <subdomain> with the actual values from your Nhost project dashboard.
5

Create the AsyncStorage Adapter

Build a storage adapter that enables Nhost to persist sessions using React Native’s AsyncStorage. This provides session persistence across app restarts and updates.
app/lib/nhost/AsyncStorage.tsx
import {
  DEFAULT_SESSION_KEY,
  type Session,
  type SessionStorageBackend,
} from "@nhost/nhost-js/session";
import AsyncStorage from "@react-native-async-storage/async-storage";

/**
 * Custom storage implementation for React Native using AsyncStorage
 * to persist the Nhost session on the device.
 *
 * This implementation synchronously works with the SessionStorageBackend interface
 * while ensuring reliable persistence with AsyncStorage for Expo Go.
 */
export default class NhostAsyncStorage implements SessionStorageBackend {
  private key: string;
  private cache: Session | null = null;

  constructor(key: string = DEFAULT_SESSION_KEY) {
    this.key = key;

    // Immediately try to load from AsyncStorage
    this.loadFromAsyncStorage();
  }

  /**
   * Load the session from AsyncStorage synchronously if possible
   */
  private loadFromAsyncStorage(): void {
    // Try to get cached data from AsyncStorage immediately
    try {
      AsyncStorage.getItem(this.key)
        .then((value) => {
          if (value) {
            try {
              this.cache = JSON.parse(value) as Session;
            } catch (error) {
              console.warn("Error parsing session from AsyncStorage:", error);
              this.cache = null;
            }
          }
        })
        .catch((error) => {
          console.warn("Error loading from AsyncStorage:", error);
        });
    } catch (error) {
      console.warn("AsyncStorage access error:", error);
    }
  }

  /**
   * Gets the session from the in-memory cache
   */
  get(): Session | null {
    return this.cache;
  }

  /**
   * Sets the session in the in-memory cache and persists to AsyncStorage
   * Ensures the data gets written by using an immediately invoked async function
   */
  set(value: Session): void {
    // Update cache immediately
    this.cache = value;

    // Persist to AsyncStorage with better error handling
    void (async () => {
      try {
        await AsyncStorage.setItem(this.key, JSON.stringify(value));
      } catch (error) {
        console.warn("Error saving session to AsyncStorage:", error);
      }
    })();
  }

  /**
   * Removes the session from the in-memory cache and AsyncStorage
   * Ensures the data gets removed by using an immediately invoked async function
   */
  remove(): void {
    // Clear cache immediately
    this.cache = null;

    // Remove from AsyncStorage with better error handling
    void (async () => {
      try {
        await AsyncStorage.removeItem(this.key);
      } catch (error) {
        console.warn("Error removing session from AsyncStorage:", error);
      }
    })();
  }
}
6

Create the Nhost Auth Provider

Build the core authentication provider that manages user sessions across your application. This component provides authentication state to all child components and handles cross-device synchronization.
app/lib/nhost/AuthProvider.tsx
import { createClient, type NhostClient } from "@nhost/nhost-js";
import type { Session } from "@nhost/nhost-js/session";
import Constants from "expo-constants";
import {
  createContext,
  type ReactNode,
  useContext,
  useEffect,
  useMemo,
  useState,
} from "react";
import NhostAsyncStorage from "./AsyncStorage";

/**
 * Authentication context interface providing access to user session state and Nhost client.
 * Used throughout the React Native application to access authentication-related data and operations.
 */
interface AuthContextType {
  /** Current authenticated user object, null if not authenticated */
  user: Session["user"] | null;
  /** Current session object containing tokens and user data, null if no active session */
  session: Session | null;
  /** Boolean indicating if user is currently authenticated */
  isAuthenticated: boolean;
  /** Boolean indicating if authentication state is still loading */
  isLoading: boolean;
  /** Nhost client instance for making authenticated requests */
  nhost: NhostClient;
}

// Create React context for authentication state and nhost client
const AuthContext = createContext<AuthContextType | null>(null);

interface AuthProviderProps {
  children: ReactNode;
}

/**
 * AuthProvider component that provides authentication context to the React Native application.
 *
 * This component handles:
 * - Initializing the Nhost client with AsyncStorage for persistent storage
 * - Managing authentication state (user, session, loading, authenticated status)
 * - Cross-device session synchronization using sessionStorage.onChange events
 * - Async session initialization to work with React Native's AsyncStorage
 */
export const AuthProvider = ({ children }: AuthProviderProps) => {
  const [user, setUser] = useState<Session["user"] | null>(null);
  const [session, setSession] = useState<Session | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);

  // Create the nhost client with persistent storage
  const nhost = useMemo(() => {
    // Get configuration values with type assertion
    const subdomain =
      (Constants.expoConfig?.extra?.["NHOST_SUBDOMAIN"] as string) || "local";
    const region =
      (Constants.expoConfig?.extra?.["NHOST_REGION"] as string) || "local";

    return createClient({
      subdomain,
      region,
      storage: new NhostAsyncStorage(),
    });
  }, []);

  useEffect(() => {
    // Initialize authentication state
    setIsLoading(true);

    // Allow enough time for AsyncStorage to be read and session to be restored
    const initializeSession = async () => {
      try {
        // Let's wait a bit to ensure AsyncStorage has been read
        await new Promise((resolve) => setTimeout(resolve, 100));

        // Now try to get the current session
        const currentSession = nhost.getUserSession();

        setUser(currentSession?.user || null);
        setSession(currentSession);
        setIsAuthenticated(!!currentSession);
      } catch (error) {
        console.warn("Error initializing session:", error);
      } finally {
        setIsLoading(false);
      }
    };

    void initializeSession();

    // Listen for session changes
    const unsubscribe = nhost.sessionStorage.onChange((currentSession) => {
      setUser(currentSession?.user || null);
      setSession(currentSession);
      setIsAuthenticated(!!currentSession);
    });

    // Clean up subscription on unmount
    return () => {
      unsubscribe();
    };
  }, [nhost]);

  // Context value with nhost client directly exposed
  const value: AuthContextType = {
    user,
    session,
    isAuthenticated,
    isLoading,
    nhost,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

/**
 * Custom hook to access the authentication context.
 *
 * Must be used within a component wrapped by AuthProvider.
 * Provides access to current user session, authentication state, and Nhost client.
 *
 * @throws {Error} When used outside of AuthProvider
 * @returns {AuthContextType} Authentication context containing user, session, and client
 *
 * @example
 * ```tsx
 * function MyComponent() {
 *   const { user, isAuthenticated, nhost } = useAuth();
 *
 *   if (!isAuthenticated) {
 *     return <Text>Please sign in</Text>;
 *   }
 *
 *   return <Text>Welcome, {user?.displayName}!</Text>;
 * }
 * ```
 */
export const useAuth = (): AuthContextType => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
};

export default AuthProvider;
7

Create Shared Theme and Styles

Before building components, let’s create shared theme constants and common styles to keep our code clean and maintainable. This follows React Native best practices for styling.
Create the theme constants file:
app/styles/theme.ts
/**
 * Design system constants for consistent theming across the app.
 * These values are used throughout the application for colors, spacing, and typography.
 */

export const colors = {
  // Primary brand colors
  primary: "#6366f1",
  primaryHover: "#5855eb",
  secondary: "#818cf8",

  // Background colors
  background: "#f5f5f5",
  surface: "#ffffff",
  overlay: "rgba(0, 0, 0, 0.5)",

  // Text colors
  text: "#333333",
  textLight: "#666666",
  textDark: "#1a1a1a",
  textPlaceholder: "#999999",

  // Status colors
  success: "#10b981",
  successLight: "#34d399",
  error: "#ef4444",
  errorLight: "#f87171",
  warning: "#f59e0b",
  info: "#3b82f6",

  // Border and divider colors
  border: "#e5e5e5",
  borderLight: "#f0f0f0",
  divider: "#e0e0e0",

  // Shadow color
  shadow: "#000000",
};

export const spacing = {
  xs: 4,
  sm: 8,
  md: 12,
  lg: 16,
  xl: 20,
  xxl: 24,
  xxxl: 32,
};

export const borderRadius = {
  sm: 6,
  md: 8,
  lg: 10,
  xl: 12,
  round: 50,
};

export const typography = {
  // Font sizes
  sizes: {
    xs: 12,
    sm: 14,
    md: 16,
    lg: 18,
    xl: 20,
    xxl: 24,
    xxxl: 28,
  },

  // Font weights
  weights: {
    normal: "400" as const,
    medium: "500" as const,
    semibold: "600" as const,
    bold: "700" as const,
  },

  // Line heights
  lineHeights: {
    tight: 1.2,
    normal: 1.4,
    relaxed: 1.6,
  },
};

export const shadows = {
  small: {
    shadowColor: colors.shadow,
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 2,
    elevation: 2,
  },
  medium: {
    shadowColor: colors.shadow,
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 3.84,
    elevation: 5,
  },
  large: {
    shadowColor: colors.shadow,
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.15,
    shadowRadius: 8,
    elevation: 8,
  },
};
8

Create the Protected Screen Component

Build a reusable component that wraps protected screens and handles authentication checks. This component prevents unauthorized access and provides loading states during authentication verification.
app/components/ProtectedScreen.tsx
import { router } from "expo-router";
import type React from "react";
import { useEffect } from "react";
import { ActivityIndicator, Text, View } from "react-native";
import { useAuth } from "../lib/nhost/AuthProvider";
import { commonStyles } from "../styles/commonStyles";
import { colors } from "../styles/theme";

type AppRoutes = "/" | "/signin" | "/signup" | "/profile";

interface ProtectedScreenProps {
  children: React.ReactNode;
  redirectTo?: AppRoutes;
}

/**
 * ProtectedScreen component that wraps screens requiring authentication.
 * Automatically redirects unauthenticated users to the signin screen.
 * Shows loading spinner while checking authentication status.
 */
export default function ProtectedScreen({
  children,
  redirectTo = "/signin",
}: ProtectedScreenProps) {
  const { isAuthenticated, isLoading } = useAuth();

  useEffect(() => {
    if (!isLoading && !isAuthenticated) {
      router.replace(redirectTo);
    }
  }, [isAuthenticated, isLoading, redirectTo]);

  if (isLoading) {
    return (
      <View style={commonStyles.loadingContainer}>
        <ActivityIndicator size="large" color={colors.primary} />
        <Text style={commonStyles.loadingText}>Loading...</Text>
      </View>
    );
  }

  if (!isAuthenticated) {
    return null; // Will redirect in useEffect
  }

  return <>{children}</>;
}
9

Create the Profile Screen

Create a screen that displays user information. Note that the screen itself doesn’t need any special authentication logic - the screen protection is handled entirely by wrapping it with the ProtectedScreen component from the previous step.
app/profile.tsx
import { ScrollView, Text, View } from "react-native";
import ProtectedScreen from "./components/ProtectedScreen";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles, profileStyles } from "./styles/commonStyles";

export default function Profile() {
  const { user, session } = useAuth();

  return (
    <ProtectedScreen>
      <ScrollView
        style={commonStyles.container}
        contentContainerStyle={commonStyles.contentContainer}
      >
        <Text style={commonStyles.title}>Your Profile</Text>

        <View style={commonStyles.card}>
          <Text style={commonStyles.cardTitle}>User Information</Text>

          <View style={profileStyles.profileItem}>
            <Text style={commonStyles.labelText}>Display Name:</Text>
            <Text style={commonStyles.valueText}>
              {user?.displayName || "Not set"}
            </Text>
          </View>

          <View style={profileStyles.profileItem}>
            <Text style={commonStyles.labelText}>Email:</Text>
            <Text style={commonStyles.valueText}>
              {user?.email || "Not available"}
            </Text>
          </View>

          <View style={profileStyles.profileItem}>
            <Text style={commonStyles.labelText}>User ID:</Text>
            <Text
              style={commonStyles.valueText}
              numberOfLines={1}
              ellipsizeMode="middle"
            >
              {user?.id || "Not available"}
            </Text>
          </View>

          <View style={profileStyles.profileItem}>
            <Text style={commonStyles.labelText}>Roles:</Text>
            <Text style={commonStyles.valueText}>
              {user?.roles?.join(", ") || "None"}
            </Text>
          </View>

          <View
            style={[profileStyles.profileItem, profileStyles.profileItemLast]}
          >
            <Text style={commonStyles.labelText}>Email Verified:</Text>
            <Text
              style={[
                commonStyles.valueText,
                user?.emailVerified
                  ? commonStyles.successText
                  : commonStyles.errorText,
              ]}
            >
              {user?.emailVerified ? "✓ Yes" : "✗ No"}
            </Text>
          </View>
        </View>

        <View style={commonStyles.card}>
          <Text style={commonStyles.cardTitle}>Session Information</Text>
          <View style={commonStyles.sessionInfo}>
            <Text style={commonStyles.sessionValue}>
              {JSON.stringify(session, null, 2)}
            </Text>
          </View>
        </View>
      </ScrollView>
    </ProtectedScreen>
  );
}

10

Update the Home Screen

Build a public homepage that adapts its content based on authentication status. This shows users different options depending on whether they’re signed in.
app/index.tsx
import { useRouter } from "expo-router";
import { Text, TouchableOpacity, View } from "react-native";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles, homeStyles } from "./styles/commonStyles";

export default function Index() {
  const router = useRouter();
  const { isAuthenticated, user } = useAuth();

  return (
    <View style={commonStyles.centerContent}>
      <Text style={commonStyles.title}>Welcome to Nhost React Native Demo</Text>

      <View style={homeStyles.welcomeCard}>
        {isAuthenticated ? (
          <View style={{ gap: 15, width: "100%" }}>
            <Text style={homeStyles.welcomeText}>
              Hello, {user?.displayName || user?.email}!
            </Text>
            <TouchableOpacity
              style={[commonStyles.button, commonStyles.fullWidth]}
              onPress={() => router.push("/profile")}
            >
              <Text style={commonStyles.buttonText}>Go to Profile</Text>
            </TouchableOpacity>
          </View>
        ) : (
          <Text style={homeStyles.authMessage}>You are not signed in.</Text>
        )}
        {/* Placeholder for signin/signup buttons - will be added in the next tutorial */}
      </View>
    </View>
  );
}
11

Update the Main App Layout

Configure the application’s routing structure with Expo Router. This sets up the route hierarchy, includes the authentication context to the entire app, and configures the navigation.
app/_layout.tsx
import { Stack } from "expo-router";
import { AuthProvider } from "./lib/nhost/AuthProvider";
import { colors } from "./styles/theme";

/**
 * Root layout component that provides authentication context to the entire app.
 * Uses Expo Router's Stack navigation for screen management.
 */
export default function RootLayout() {
  return (
    <AuthProvider>
      <Stack>
        <Stack.Screen
          name="index"
          options={{
            title: "Home",
            headerStyle: { backgroundColor: colors.primary },
            headerTintColor: colors.surface,
            headerTitleStyle: { fontWeight: "bold" },
          }}
        />
        <Stack
          screenOptions={{
            headerStyle: { backgroundColor: colors.primary },
            headerTintColor: colors.surface,
            headerTitleStyle: { fontWeight: "bold" },
          }}
        />
      </Stack>
    </AuthProvider>
  );
}
12

Run and test the Application

Start the development server to test your screen protection implementation:
npm run start
Things to try out:
  1. Open the app in Expo Go on your device or simulator.
  2. Because you are not signed in, the home screen should show a message indicating that you are not signed in.
  3. Additionally the profle button should not be visible.

How It Works

  1. AuthProvider: Manages authentication state using Nhost’s client with AsyncStorage and provides it through React Context
  2. ProtectedScreen: A wrapper component that checks authentication status before rendering child screens
  3. Profile Screen: A protected screen that displays user information, only accessible when authenticated

Key Features Demonstrated