Build a Food Ordering App with React Native & Supabase

Based on a tutorial by Not Just Dev

My Take:

Expo's push notification system simplifies what would otherwise be a complex integration requiring separate implementations for iOS and Android. The NotificationProvider pattern is clean and keeps all notification logic in one place. I particularly like the approach of storing push tokens in the user profile, which enables targeted notifications like order status updates. For a production app, you'd want to implement more robust error handling and possibly a notification history screen.

Are you struggling to build a complete mobile food ordering application that works across both iOS and Android? Many developers find it challenging to implement both the frontend and backend components while ensuring everything works seamlessly together.

This comprehensive guide summarizes the "Zero to Hero" workshop on building a full-stack food ordering application (similar to Domino's Pizza) using React Native and Supabase. The tutorial covers everything from setting up your development environment to implementing advanced features like payment processing and push notifications.

Setting Up Your Environment & Project Structure (00:00-10:15)

The tutorial begins with setting up the development environment and creating a fresh Expo project with TypeScript support. The instructor emphasizes using Expo for its simplicity and excellent developer experience.

Key Points:

  • Required dependencies include Node.js (LTS version) and Expo Go app on your physical device
  • Optional tools include Git, Watchman (for Linux/macOS), and a code editor (VS Code recommended)
  • The project uses Expo's navigation template with TypeScript support
  • All source code is organized within a src directory for clean project organization
  • Dummy data is imported from an asset bundle for development purposes

# Create a new Expo application with the navigation template
npx create-expo-app@latest food-ordering -t navigation-typescript

# Start the development server
npm start
    

My Take:

Organizing your code in a structured manner from the beginning is crucial for maintainability. The approach of keeping all source code in an src directory is a best practice that makes it easier to distinguish between application code and configuration files. I also appreciate the emphasis on using TypeScript, which provides type safety and better developer tooling.

Building UI Components & Navigation (10:16-59:40)

This section covers building the UI for the product listing screen, product details, and implementing navigation between screens using Expo Router. The instructor explains how to work with built-in React Native components, create custom components, and implement navigation flows.

Key Points:

  • Creating responsive UI elements using View, Text, and Image components
  • Building custom ProductListItem component with proper TypeScript typing
  • Using FlatList to render scrollable product lists with multiple columns
  • Implementing Stack and Tab navigation using Expo Router
  • Creating dynamic routes with parameters for product details pages
  • Setting up nested navigation (tabs inside stack navigator)
  • Managing state for active product size selection

// Register for push notifications
export const registerForPushNotifications = async () => {
  // Check if device is physical (not simulator)
  if (!Device.isDevice) {
    alert('Must use a physical device for push notifications');
    return null;
  }
  
  // Check existing permissions
  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;
  
  // Request permissions if not granted
  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }
  
  if (finalStatus !== 'granted') {
    alert('Failed to get push token for push notification!');
    return null;
  }
  
  // Get Expo push token
  const token = (await Notifications.getExpoPushTokenAsync({
    projectId: Constants.expoConfig?.extra?.eas?.projectId,
  })).data;
  
  return token;
// Send notifications to users when order status changes
export const notifyUserAboutOrderUpdate = async (order: Tables<'orders'>) => {
  // Get user token from database
  const token = await getUserPushToken(order.user_id);
  
  if (!token) {
    console.log('User has no push token');
    return;
  }
  
  // Send notification with status update
  await sendPushNotification(
    token,
    `Your order is ${order.status}`,
    `We'll update you when there's another change in your order status.`
  );
};

// Function to actually send push notification
export const sendPushNotification = async (
  token: string,
  title: string,
  body: string
) => {
  const message = {
    to: token,
    sound: 'default',
    title,
    body,
  };

  await fetch('https://exp.host/--/api/v2/push/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(message),
  });
};

// Notification Provider that manages push notification token and listeners
const NotificationProvider = ({ children }: PropsWithChildren) => {
  const [expoPushToken, setExpoPushToken] = useState();
  const { profile } = useAuth();
  
  // Set up notification handlers
  useEffect(() => {
    // Set notification handler configuration
    Notifications.setNotificationHandler({
      handleNotification: async () => ({
        shouldShowAlert: true,
        shouldPlaySound: true,
        shouldSetBadge: false,
      }),
    });
    
    // Register device and get token
    const registerDevice = async () => {
      const token = await registerForPushNotifications();
      if (!token) return;
      
      setExpoPushToken(token);
      
      // Save token to user profile in database
      if (profile) {
        await supabase
          .from('profiles')
          .update({ expo_push_token: token })
          .eq('id', profile.id);
      }
    };
    
    registerDevice();
    
    // Set up notification listeners
    const notificationListener = Notifications.addNotificationReceivedListener(
      notification => {
        console.log('Notification received', notification);
      }
    );
    
    const responseListener = Notifications.addNotificationResponseReceivedListener(
      response => {
        console.log('Notification response', response);
        // Handle notification tap/response here
      }
    );
    
    // Clean up listeners on unmount
    return () => {
      Notifications.removeNotificationSubscription(notificationListener);
      Notifications.removeNotificationSubscription(responseListener);
    };
  }, [profile]);
  
  return <>{children};
};
// Example of a custom component with TypeScript
const ProductListItem = ({ product }: { product: Product }) => {
  return (
    
      
      {product.name}
      ${product.price}
    
  );
};
    

My Take:

Expo Router's file-based navigation system makes it incredibly intuitive to create complex navigation flows. The approach of using folders and files to define routes is similar to Next.js, making it familiar for web developers transitioning to mobile. The implementation of nested navigation (having a stack navigator inside a tab navigator) demonstrates how flexible and powerful this system can be for real-world applications.

Managing State with Context Providers (59:41-1:35:20)

This section focuses on implementing the shopping cart functionality using React Context API. The instructor demonstrates how to create context providers, share state across components, and manage cart operations like adding items and calculating totals.

Key Points:

  • Creating a CartProvider using React Context API
  • Implementing cart state management (add, update quantity, remove)
  • Building the cart UI with item listing and checkout button
  • Creating a modal for the cart screen
  • Calculating cart totals dynamically
  • Handling duplicate items by incrementing quantity
  • Type-safe implementation with TypeScript

// Cart context provider with TypeScript
export const CartContext = createContext({
  items: [],
  addItem: () => {},
  updateQuantity: () => {},
  total: 0,
  checkout: () => {},
});

export const useCart = () => useContext(CartContext);

const CartProvider = ({ children }: PropsWithChildren) => {
  const [items, setItems] = useState([]);
  
  // Calculate total price of all items in cart
  const total = items.reduce(
    (sum, item) => sum + item.product.price * item.quantity, 
    0
  );
  
  // Add item to cart
  const addItem = (product: Product, size: PizzaSize) => {
    // Check if item already exists
    const existingItem = items.find(
      (item) => item.product.id === product.id && item.size === size
    );
    
    if (existingItem) {
      updateQuantity(existingItem.id, 1);
      return;
    }
    
    // Add new item
    setItems([
      {
        id: randomUUID(),
        product,
        size,
        quantity: 1,
      },
      ...items,
    ]);
  };
  
  // More functions...
  
  return (
    
      {children}
    
  );
};
    

My Take:

Using React Context is an excellent choice for managing cart state across components without prop drilling. The implementation demonstrates good practices like creating a custom hook (useCart) to simplify accessing the context from any component. The approach to merging identical items by incrementing quantity rather than duplicating entries shows attention to real-world user experience considerations.

Backend Integration with Supabase (1:35:21-2:30:50)

This section introduces Supabase as the backend solution for the application. The instructor explains how to set up a Supabase project, configure it for the application, and implement backend features.

Key Points:

  • Creating and configuring a Supabase project
  • Setting up the Supabase client in React Native
  • Creating database tables with proper relations
  • Implementing Row Level Security (RLS) policies
  • Running Supabase locally for development
  • Creating Edge Functions for server-side operations
  • Setting up TypeScript integration with Supabase

// Supabase client configuration
import { createClient } from '@supabase/supabase-js';
import { Database } from './database.types';

const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient(supabaseUrl, supabaseKey);
    

My Take:

Supabase provides an impressive alternative to Firebase with its open-source approach and PostgreSQL foundation. The Row Level Security policies are particularly powerful for implementing authorization rules directly at the database level. Running Supabase locally for development is a great workflow that allows testing changes before deploying to production, though the setup process requires some additional steps compared to Firebase.

Authentication & User Management (2:30:51-3:05:30)

This section covers implementing authentication with Supabase Auth, creating sign-in and sign-up screens, and protecting routes based on authentication state. The instructor also demonstrates how to implement role-based access control.

Key Points:

  • Creating sign-in and sign-up forms with validation
  • Implementing authentication with Supabase Auth
  • Building an AuthProvider context to manage authentication state
  • Protecting routes based on authentication status
  • Setting up role-based access control (user vs. admin)
  • Creating user profiles linked to auth users
  • Managing session state and redirections

// Sign in with email implementation
const signInWithEmail = async () => {
  setLoading(true);
  
  const { error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });
  
  if (error) {
    Alert.alert(error.message);
  }
  
  setLoading(false);
};

// AuthProvider checking session on mount
useEffect(() => {
  const fetchSession = async () => {
    const { data: { session }, error } = await supabase.auth.getSession();
    setSession(session);
    setLoading(false);
    
    if (session?.user) {
      // Fetch user profile with role information
      const { data } = await supabase
        .from('profiles')
        .select('*')
        .eq('id', session.user.id)
        .single();
        
      setProfile(data);
      setIsAdmin(data?.group === 'admin');
    }
  };
  
  fetchSession();
  
  // Listen for auth changes
  const { data } = supabase.auth.onAuthStateChange((_event, session) => {
    setSession(session);
  });
  
  return () => data.subscription.unsubscribe();
}, []);
    

My Take:

The implementation of authentication with Supabase is straightforward and follows best practices with a dedicated context provider. The approach of defining a separate profiles table that extends the auth.users table is elegant, allowing for additional user metadata like roles without modifying the core authentication tables. The session management with real-time updates ensures the app stays in sync with authentication state changes.

Database Operations & TypeScript Integration (3:05:31-3:45:15)

This section focuses on implementing CRUD operations for products and orders using React Query and Supabase. The instructor also demonstrates how to integrate TypeScript with Supabase to get type safety for database operations.

Key Points:

  • Setting up React Query for data fetching and caching
  • Creating custom hooks for database operations
  • Implementing CRUD operations for products
  • Building order management with relationships
  • Generating TypeScript types from Supabase schema
  • Setting up real-time subscriptions for live updates
  • Implementing proper error handling and loading states

// Custom hook for fetching products using React Query
export const useProductList = () => {
  return useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('products')
        .select('*')
        .order('created_at', { ascending: false });
        
      if (error) {
        throw new Error(error.message);
      }
      
      return data;
    },
  });
};

// Custom hook for updating a product
export const useUpdateProduct = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async ({ 
      id, 
      updatedFields 
    }: { 
      id: number; 
      updatedFields: Tables<'products'> 
    }) => {
      const { data, error } = await supabase
        .from('products')
        .update(updatedFields)
        .eq('id', id)
        .select()
        .single();
        
      if (error) {
        throw new Error(error.message);
      }
      
      return data;
    },
    onSuccess: (_, { id }) => {
      // Invalidate queries to refetch data
      queryClient.invalidateQueries(['products']);
      queryClient.invalidateQueries(['products', id]);
    },
  });
};
    

My Take:

The combination of React Query and Supabase creates a powerful data management solution. React Query handles caching, loading states, and invalidation, while Supabase provides the database operations. The implementation of custom hooks for each operation keeps the components clean and focused on rendering. Generating TypeScript types from the Supabase schema ensures type safety across the entire application, catching potential errors at compile time.

File Storage & Image Management (3:45:16-4:00:10)

This section covers implementing image upload and storage using Supabase Storage. The instructor demonstrates how to create storage buckets, upload images, and handle image display in the application.

Key Points:

  • Setting up Supabase Storage buckets
  • Implementing image picking with Expo ImagePicker
  • Uploading images to Supabase Storage
  • Creating a custom RemoteImage component for image display
  • Handling fallback images for missing content
  • Setting up storage permissions and policies
  • Linking stored images with database records

// Upload image to Supabase Storage
const uploadImage = async (uri: string) => {
  try {
    const response = await FileSystem.readAsStringAsync(uri, {
      encoding: FileSystem.EncodingType.Base64,
    });
    
    const fileName = `${randomUUID()}.png`;
    const contentType = 'image/png';
    
    const { data, error } = await supabase.storage
      .from('product-images')
      .upload(fileName, decode(response), {
        contentType,
      });
      
    if (error) {
      throw error;
    }
    
    return data.path;
  } catch (e) {
    console.log(e);
    return null;
  }
};

// RemoteImage component for displaying images from Supabase Storage
const RemoteImage = ({ path, fallback, ...props }: RemoteImageProps) => {
  const [image, setImage] = useState(null);
  
  useEffect(() => {
    if (!path) return;
    
    const downloadImage = async () => {
      try {
        const { data, error } = await supabase.storage
          .from('product-images')
          .download(path);
          
        if (error) {
          throw error;
        }
        
        const fileReader = new FileReader();
        fileReader.readAsDataURL(data);
        fileReader.onload = () => {
          setImage(fileReader.result as string);
        };
      } catch (e) {
        console.log(e);
      }
    };
    
    downloadImage();
  }, [path]);
  
  if (!image && !fallback) {
    return null;
  }
  
  return (
    
  );
};
    

My Take:

The RemoteImage component is an elegant solution for handling images stored in Supabase Storage. By encapsulating the download logic within the component, it simplifies the use of remote images throughout the application. The approach of generating random UUIDs for filenames prevents collisions and the implementation of fallback images ensures a good user experience even when images fail to load.

Payment Processing with Stripe (4:00:11-4:30:25)

This section covers integrating Stripe for payment processing in the application. The instructor demonstrates how to create a Supabase Edge Function for creating payment intents and implementing the Stripe payment sheet in React Native.

Key Points:

  • Setting up a Stripe account and obtaining API keys
  • Creating a Supabase Edge Function for payment intent creation
  • Implementing the Stripe React Native library
  • Building a payment flow in the checkout process
  • Handling payment success and failure states
  • Setting up customer profiles in Stripe
  • Implementing saved payment methods for returning customers

// Supabase Edge Function for creating payment intent
Deno.serve(async (req) => {
  try {
    const { amount } = await req.json();
    
    // Get user from auth context
    const user = await getUser(req);
    const profile = await getProfile(user.id);
    
    // Create or retrieve Stripe customer
    let customerId = profile.stripe_customer_id;
    if (!customerId) {
      // Create new Stripe customer
      const customer = await stripe.customers.create({
        email: user.email,
        metadata: {
          userId: user.id,
        },
      });
      
      customerId = customer.id;
      
      // Save customer ID to profile
      await supabase
        .from('profiles')
        .update({ stripe_customer_id: customerId })
        .eq('id', profile.id);
    }
    
    // Create ephemeral key for customer
    const ephemeralKey = await stripe.ephemeralKeys.create(
      { customer: customerId },
      { apiVersion: '2020-08-27' }
    );
    
    // Create payment intent
    const paymentIntent = await stripe.paymentIntents.create({
      amount,
      currency: 'usd',
      customer: customerId,
    });
    
    return new Response(
      JSON.stringify({
        paymentIntent: paymentIntent.client_secret,
        ephemeralKey: ephemeralKey.secret,
        customer: customerId,
        publishableKey: Deno.env.get('EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY'),
      }),
      { headers: { 'Content-Type': 'application/json' } }
    );
  } catch (error) {
    return new Response(
      JSON.stringify({ error: error.message }),
      { status: 400, headers: { 'Content-Type': 'application/json' } }
    );
  }
});

// React Native implementation of payment sheet
const initializePaymentSheet = async (amount: number) => {
  const { paymentIntent, ephemeralKey, customer } = 
    await fetchPaymentSheetParams(amount);
    
  if (!paymentIntent || !ephemeralKey || !customer) {
    return false;
  }
  
  const { error } = await initPaymentSheet({
    merchantDisplayName: 'Not Just Dev',
    customerId: customer,
    customerEphemeralKeySecret: ephemeralKey,
    paymentIntentClientSecret: paymentIntent,
  });
  
  if (error) {
    Alert.alert(error.message);
    return false;
  }
  
  return true;
};

const openPaymentSheet = async () => {
  const { error } = await presentPaymentSheet();
  
  if (error) {
    Alert.alert(error.message);
    return false;
  }
  
  return true;
};
    

My Take:

The approach of using a Supabase Edge Function for creating payment intents is secure and elegant. It keeps the Stripe secret key on the server side while allowing the client to communicate with Stripe for payment collection. The implementation of customer profiles enables a better user experience for returning customers, allowing them to save payment methods for future orders. The integration demonstrates a professional approach to payment processing in mobile applications.

Push Notifications with Expo (4:30:26-5:00:00)

The final section covers implementing push notifications using Expo's notification service. The instructor demonstrates how to set up notifications, request permissions, and send notifications for order status updates.

Key Points:

  • Setting up Expo notifications library
  • Requesting notification permissions
  • Storing Expo push tokens in the database
  • Creating a NotificationProvider context
  • Implementing order status update notifications
  • Sending notifications to admins for new orders
  • Handling notification responses in the app

// Register for push notifications
export const registerForPushNotifications = async () => {
  // Check if device is physical (not simulator)
  if (!Device.isDevice) {
    alert('Must use a physical device for push notifications');
    return null;
  }
  
  // Check existing permissions
  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;
  
  // Request permissions if not granted
  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }
  
  if (finalStatus !== 'granted') {
    alert('Failed to get push token for push notification!');
    return null;
  }
  
  // Get Expo push token
  const token = (await Notifications.getExpoPushTokenAsync({
    projectId: Constants.expoConfig?.extra?.eas?.projectId,
  })).data;
  
  return token;
};

// Notification Provider implementation
const NotificationProvider = ({ children }: PropsWithChildren) => {
  const [expoPushToken, setExpoPushToken] = useState();
  const { profile } = useAuth();
  
  // Set notification handler
  useEffect(() => {
    Notifications.setNotificationHandler({
      handleNotification: async () => ({
        shouldShowAlert: true,
        shouldPlaySound: true,
        shouldSetBadge: false,
      }),
    });
  }, []);
  
  // Register for push notifications on mount
  useEffect(() => {
    const registerDevice = async () => {
      const token = await registerForPushNotifications();
      if (!token) return;
      
      setExpoPushToken(token);
      
      // Save token to user profile in database
      if (profile) {
        await supabase
          .from('profiles')
          .update({ expo_push_token: token })
          .eq('id', profile.id);
      }
    };
    
    registerDevice();
    
    // Set up notification listeners
    const notificationListener = Notifications.addNotificationReceivedListener(
      notification => {
        console.log('Notification received', notification);
      }
    );
    
    const responseListener = Notifications.addNotificationResponseReceivedListener(
      response => {
        console.log('Notification response', response);
      }
    );
    
    return () => {
      if (notificationListener) {
        Notifications.removeNotificationSubscription(notificationListener);
      }
      if (responseListener) {
        Notifications.removeNotificationSubscription(responseListener);
      }
    };
  }, [profile]);
  
  return <>{children};
};

// Send notification about order status update
export const notifyUserAboutOrderUpdate = async (order: Tables<'orders'>) => {
  const token = await getUserPushToken(order.user_id);
  
  if (!token) {
    console.log('User has no push token');
    return;
  }
  
  await sendPushNotification(
    token,
    `Your order is ${order.status}`,
    `We'll update you when there's a change in your order status.`
  );
};

// Send push notification function
export const sendPushNotification = async (
  token: string,
  title: string,
  body: string
) => {
  const message = {
    to: token,
    sound: 'default',
    title,
    body,
  };

  await fetch('https://exp.host/--/api/v2/push/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(message),
  });
};

Comments