Implement Mobile App with React Native, Expo, and Medusa

In this tutorial, you'll learn how to create a mobile app that connects to your Medusa backend with React Native and Expo. You can then publish your app to the Apple App Store and Google Play Store.

When you install a Medusa application, you get a fully-fledged commerce server and an admin dashboard to manage the commerce store's data. Medusa's architecture is flexible and customizable, allowing you to build your own custom storefronts using any technology you prefer.

React Native allows developers to build native apps using React. Expo is a platform for universal React applications that makes it easy to build, deploy, and iterate on native iOS and Android apps.

Summary#

By following this tutorial, you'll learn how to:

  1. Set up a Medusa application.
  2. Create an app with React Native and Expo.
  3. Connect the Expo app to the Medusa backend.
  4. Implement essential ecommerce features such as product listing, cart management, and checkout.

You can follow this guide whether you're new to Medusa or an advanced Medusa developer. However, this tutorial assumes you have a basic understanding of React Native and JavaScript.

Screenshot of home screen of the Expo app

Full Code
Find the full code for this tutorial in this repository.

Step 1: Install a Medusa Application#

Start by installing the Medusa application on your machine with the following command:

Terminal
npx create-medusa-app@latest

You'll first be asked for the project's name. Then, when you're asked whether you want to install the Next.js Starter Storefront, choose N for no. You'll create the Expo app instead.

Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name. Once the installation finishes successfully, the Medusa Admin dashboard will open in your browser at http://localhost:9000/app with a form to create a new user. Enter the user's credentials and submit the form.

Then, you can log in with the new user and explore the dashboard.

Ran into Errors? Check out the troubleshooting guides for help.

Step 2: Create an Expo App#

In this step, you'll create a new Expo app with React Native.

In a directory separate from your Medusa application, run the following command to create a new Expo project:

Terminal
npx create-expo-app@latest

When prompted, enter a name for your project and wait for the installation to complete.

Once the installation is complete, navigate to the project directory:

Terminal
cd your-project-name

The rest of this tutorial assumes you're in the Expo app's root directory.


Step 3: Install Dependencies#

In this step, you'll install dependencies that you'll use while building the Expo app.

Run the following command to install the required packages:

You install the following packages:

  • @medusajs/js-sdk: Medusa's JS SDK to interact with the Medusa backend.
  • @medusajs/types: TypeScript types for Medusa, which are useful when working with Medusa's JS SDK.
  • @react-native-async-storage/async-storage: An asynchronous key-value storage system for React Native, used to store data like cart ID.
  • @react-native-picker/picker: A cross-platform picker component for React Native, used for selecting options like country.
  • @react-navigation/drawer: A navigation library for React Native that provides a drawer-based navigation experience.

You'll use these packages in the upcoming steps to build the app's functionality.


Step 4: Update App Theme (Optional)#

In this step, you'll update the theme for the app to ensure a consistent look and feel across all screens. This is optional, and you can customize the theme based on your brand identity instead.

In your React Native project, you should have the following directories:

  • constants: This directory will contain constant values used throughout the app. It should already have a theme.ts file.
  • hooks: This directory will contain custom React hooks. It should already have hooks for color schemes: use-color-scheme.ts, use-color-scheme.web.ts, and use-theme-color.ts.

To update the app's theme, replace the content of the constants/theme.ts file with the following:

constants/theme.ts
1/**2 * Below are the colors that are used in the app. The colors are defined in the light and dark mode.3 * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.4 */5
6import { Platform } from "react-native"7
8const tintColorLight = "#000"9const tintColorDark = "#fff"10
11export const Colors = {12  light: {13    text: "#11181C",14    background: "#fff",15    tint: tintColorLight,16    icon: "#687076",17    tabIconDefault: "#687076",18    tabIconSelected: tintColorLight,19    border: "#e0e0e0",20    cardBackground: "#f9f9f9",21    error: "#ff3b30",22    warning: "#ff9500",23    success: "#4CAF50",24    imagePlaceholder: "#f0f0f0",25  },26  dark: {27    text: "#ECEDEE",28    background: "#151718",29    tint: tintColorDark,30    icon: "#9BA1A6",31    tabIconDefault: "#9BA1A6",32    tabIconSelected: tintColorDark,33    border: "#333",34    cardBackground: "#1a1a1a",35    error: "#ff3b30",36    warning: "#ff9500",37    success: "#4CAF50",38    imagePlaceholder: "#2a2a2a",39  },40}41
42export const Fonts = Platform.select({43  ios: {44    /** iOS `UIFontDescriptorSystemDesignDefault` */45    sans: "system-ui",46    /** iOS `UIFontDescriptorSystemDesignSerif` */47    serif: "ui-serif",48    /** iOS `UIFontDescriptorSystemDesignRounded` */49    rounded: "ui-rounded",50    /** iOS `UIFontDescriptorSystemDesignMonospaced` */51    mono: "ui-monospace",52  },53  default: {54    sans: "normal",55    serif: "serif",56    rounded: "normal",57    mono: "monospace",58  },59  web: {60    sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",61    serif: "Georgia, 'Times New Roman', serif",62    rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",63    mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",64  },65})

Step 5: Initialize Medusa JS SDK#

In this step, you'll set up Medusa's JS SDK in your Expo app. You'll use the SDK to interact with the Medusa backend.

To initialize the SDK, create the file lib/sdk.ts in your Expo project with the following content:

lib/sdk.ts
1import Medusa from "@medusajs/js-sdk"2import AsyncStorage from "@react-native-async-storage/async-storage"3import Constants from "expo-constants"4
5const MEDUSA_BACKEND_URL =6  Constants.expoConfig?.extra?.EXPO_PUBLIC_MEDUSA_URL ||7  process.env.EXPO_PUBLIC_MEDUSA_URL ||8  "http://localhost:9000"9
10const MEDUSA_PUBLISHABLE_API_KEY = 11  Constants.expoConfig?.extra?.EXPO_PUBLIC_MEDUSA_PUBLISHABLE_API_KEY ||12  process.env.EXPO_PUBLIC_MEDUSA_PUBLISHABLE_API_KEY ||13  ""14
15export const sdk = new Medusa({16  baseUrl: MEDUSA_BACKEND_URL,17  debug: __DEV__,18  auth: {19    type: "jwt",20    jwtTokenStorageMethod: "custom",21    storage: AsyncStorage,22  },23  publishableKey: MEDUSA_PUBLISHABLE_API_KEY,24})

You configure the SDK with the:

  • Medusa backend URL, which is read from the environment variable EXPO_PUBLIC_MEDUSA_URL. If the variable is not set, it defaults to http://localhost:9000.
  • Publishable API key, which is required to retrieve products in the associated sales channel. It is read from the environment variable EXPO_PUBLIC_MEDUSA_PUBLISHABLE_API_KEY.

You also customize the JWT token storage method of the JS SDK to use AsyncStorage, which is suitable for React Native apps.

Note: Refer to the JS SDK documentation for more configuration options.

Set Environment Variables#

Next, you'll set the environment variables in your Expo project. Before you do that, you need to retrieve the Medusa backend URL and the Publishable API key.

The Medusa backend URL is the URL where your Medusa server is running. If you're running the Medusa server locally, it should be the IP address of your machine at the port 9000. For example, http://192.168.1.100:9000.

You can find your machine's local IP address by running the following command in your terminal:

Next, to get the Publishable API key, start the Medusa application by running the following command in its directory:

Then:

  1. Open the Medusa Admin dashboard at http://localhost:9000/app and log in.
  2. Go to Settings > Publishable API Keys.
  3. Either copy the key of an existing Publishable API key, or create a new one by clicking on the Create Publishable API Key button.

Once you have the Medusa backend URL and the Publishable API key, create the file .env in the root directory of your Expo project with the following content:

.env
1EXPO_PUBLIC_MEDUSA_URL=http://192.168.1.100:90002EXPO_PUBLIC_MEDUSA_PUBLISHABLE_API_KEY=your_publishable_api_key

Make sure to replace the values with your actual Medusa backend URL and Publishable API key.

Optional: Update CORS in Medusa#

If you plan to test the Expo app in a web browser, you'll need to update the CORS settings in the Medusa backend to allow requests from the Expo development server.

In your Medusa application's directory, add the Expo development server URL to the STORE_CORS and AUTH_CORS environment variables in the .env file:

Code
1STORE_CORS=previous_values...,http://localhost:80812AUTH_CORS=previous_values...,http://localhost:8081

Append ,http://localhost:8081 to the existing values of STORE_CORS and AUTH_CORS, which is the default URL for the Expo development server.

Make sure to restart the Medusa server after making changes to the .env file.


Step 6: Create Region Selector#

In this step, you'll create a region selector component that allows users to select their country and currency. This is important for providing a localized shopping experience.

To implement this, you'll:

  1. Create a region context to manage the selected region state.
  2. Create a region selector component that allows users to choose their country and currency from a list.
  3. Update the app's navigation to add a drawer menu for the region selector.

Create Region Context#

The region context will manage the selected region state and provide it to child components. You'll create a context, a provider component, and a custom hook to access the context.

To create the region context, create the file context/region-context.tsx with the following content:

context/region-context.tsx
1import { sdk } from "@/lib/sdk"2import type { HttpTypes } from "@medusajs/types"3import AsyncStorage from "@react-native-async-storage/async-storage"4import React, { createContext, ReactNode, useContext, useEffect, useState } from "react"5
6interface RegionContextType {7  regions: HttpTypes.StoreRegion[];8  selectedRegion: HttpTypes.StoreRegion | null;9  selectedCountryCode: string | null;10  setSelectedRegion: (region: HttpTypes.StoreRegion, countryCode: string) => void;11  loading: boolean;12  error: string | null;13}14
15const RegionContext = createContext<RegionContextType | undefined>(undefined)16
17const REGION_STORAGE_KEY = "selected_region_id"18const COUNTRY_STORAGE_KEY = "selected_country_code"19
20export function RegionProvider({ children }: { children: ReactNode }) {21  const [regions, setRegions] = useState<HttpTypes.StoreRegion[]>([])22  const [selectedRegion, setSelectedRegionState] = useState<HttpTypes.StoreRegion | null>(null)23  const [selectedCountryCode, setSelectedCountryCode] = useState<string | null>(null)24  const [loading, setLoading] = useState(true)25  const [error, setError] = useState<string | null>(null)26
27  // TODO load and select regions28}29
30// TODO add useRegion hook

You define the RegionContext with the following properties that child components can use:

  1. regions: An array of available regions fetched from the Medusa backend.
  2. selectedRegion: The currently selected region.
  3. selectedCountryCode: The country code of the selected country within the selected region. This is useful as regions can have multiple countries, and you'll allow customers to select their country.
  4. setSelectedRegion: A function to update the selected region and country code.
  5. loading: A boolean indicating whether the regions are being loaded.
  6. error: An error message if loading the regions fails.

You also define the RegionProvider component that will wrap the app and provide the region context to its children.

Next, you'll implement the logic to load the regions from the Medusa backend, and manage the selected region state.

Replace the // TODO load and select regions comment with the following:

context/region-context.tsx
1const loadRegions = async () => {2  try {3    setLoading(true)4    setError(null)5    6    const { regions: fetchedRegions } = await sdk.store.region.list()7    setRegions(fetchedRegions)8
9    // Load saved region and country or use first region's first country10    const savedRegionId = await AsyncStorage.getItem(REGION_STORAGE_KEY)11    const savedCountryCode = await AsyncStorage.getItem(COUNTRY_STORAGE_KEY)12    13    const regionToSelect = savedRegionId14      ? fetchedRegions.find((r) => r.id === savedRegionId) || fetchedRegions[0]15      : fetchedRegions[0]16
17    if (regionToSelect) {18      setSelectedRegionState(regionToSelect)19      await AsyncStorage.setItem(REGION_STORAGE_KEY, regionToSelect.id)20      21      // Set country code - use saved one if it exists in the region, otherwise use first country22      const countryCodeToSelect = savedCountryCode && 23        regionToSelect.countries?.some((c) => (c.iso_2 || c.id) === savedCountryCode)24        ? savedCountryCode25        : regionToSelect.countries?.[0]?.iso_2 || regionToSelect.countries?.[0]?.id || null26      27      setSelectedCountryCode(countryCodeToSelect)28      if (countryCodeToSelect) {29        await AsyncStorage.setItem(COUNTRY_STORAGE_KEY, countryCodeToSelect)30      }31    }32  } catch (err) {33    console.error("Failed to load regions:", err)34    setError("Failed to load regions. Please try again.")35  } finally {36    setLoading(false)37  }38}39
40// Load regions on mount41useEffect(() => {42  loadRegions()43}, [])44
45const setSelectedRegion = async (region: HttpTypes.StoreRegion, countryCode: string) => {46  setSelectedRegionState(region)47  setSelectedCountryCode(countryCode)48  await AsyncStorage.setItem(REGION_STORAGE_KEY, region.id)49  await AsyncStorage.setItem(COUNTRY_STORAGE_KEY, countryCode)50}51
52return (53  <RegionContext.Provider54    value={{55      regions,56      selectedRegion,57      selectedCountryCode,58      setSelectedRegion,59      loading,60      error,61    }}62  >63    {children}64  </RegionContext.Provider>65)

You define the loadRegions function that fetches the regions from the Medusa backend using the JS SDK. It also loads any previously selected region and country from AsyncStorage, or defaults to the first region and its first country.

Then, you run the loadRegions function when the component mounts using the useEffect hook.

You also define the setSelectedRegion function that updates the selected region and country, and saves them to AsyncStorage.

Then, you provide the context values to child components using the RegionContext.Provider.

Finally, you'll create a custom hook in the same file to access the region context easily. Replace the // TODO add useRegion hook comment with the following:

context/region-context.tsx
1export function useRegion() {2  const context = useContext(RegionContext)3  if (!context) {4    throw new Error("useRegion must be used within a RegionProvider")5  }6  return context7}

You define the useRegion hook that retrieves the context value using useContext. It also throws an error if the hook is used by a component that is not wrapped in the RegionProvider.

Create Region Selector Component#

Next, you'll create the region selector component that allows users to choose their country and currency.

To create the region selector component, create the file components/region-selector.tsx with the following content:

components/region-selector.tsx
1import { Colors } from "@/constants/theme"2import { useRegion } from "@/context/region-context"3import { useColorScheme } from "@/hooks/use-color-scheme"4import type { HttpTypes } from "@medusajs/types"5import React, { useMemo } from "react"6import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"7
8interface RegionSelectorProps {9  onRegionChange?: () => void;10}11
12interface CountryWithRegion {13  countryCode: string;14  countryName: string;15  region: HttpTypes.StoreRegion;16  currencyCode: string;17}18
19export function RegionSelector({ onRegionChange }: RegionSelectorProps) {20  const { 21    regions, 22    selectedRegion, 23    selectedCountryCode, 24    setSelectedRegion,25  } = useRegion()26  const colorScheme = useColorScheme()27  const colors = Colors[colorScheme ?? "light"]28
29  // Flatten countries from all regions30  const countries = useMemo(() => {31    const countryList: CountryWithRegion[] = []32    33    regions.forEach((region) => {34      if (region.countries) {35        region.countries.forEach((country) => {36          countryList.push({37            countryCode: country.iso_2 || country.id,38            countryName: country.display_name || country.name || country.iso_2 || country.id,39            region: region,40            currencyCode: region.currency_code || "",41          })42        })43      }44    })45    46    // Sort alphabetically by country name47    return countryList.sort((a, b) => a.countryName.localeCompare(b.countryName))48  }, [regions])49
50  // TODO handle country selection51}52
53const styles = StyleSheet.create({54  container: {55    flex: 1,

You define the RegionSelector component that receives an optional onRegionChange prop. This prop is a callback function that will be called when the user selects a new region.

In the component, you:

  1. Use the useRegion hook to access all regions, the selected region and country, and the function to set the selected region.
  2. Use the useColorScheme hook to get the current color scheme and apply the appropriate colors.
  3. Create a memoized list of countries by flattening the countries from all regions. Each country includes its associated region and currency code. The list is sorted alphabetically by country name.

Next, you'll implement the logic to handle country selection and render the list of countries. Replace the // TODO handle country selection comment with the following:

components/region-selector.tsx
1const handleSelectCountry = async (countryWithRegion: CountryWithRegion) => {2  setSelectedRegion(countryWithRegion.region, countryWithRegion.countryCode)3  onRegionChange?.()4}5
6const isCountrySelected = (countryWithRegion: CountryWithRegion) => {7  return selectedRegion?.id === countryWithRegion.region.id && 8          selectedCountryCode === countryWithRegion.countryCode9}10
11return (12  <ScrollView style={styles.container}>13    <Text style={[styles.title, { color: colors.text }]}>Select Country</Text>14    {countries.length === 0 ? (15      <Text style={[styles.emptyText, { color: colors.icon }]}>16        No countries available17      </Text>18    ) : (19      countries.map((country) => {20        const isSelected = isCountrySelected(country)21        22        return (23          <TouchableOpacity24            key={`${country.region.id}-${country.countryCode}`}25            style={[26              styles.countryItem,27              {28                backgroundColor: isSelected ? colors.tint + "20" : "transparent",29                borderColor: colors.icon + "30",30              },31            ]}32            onPress={() => handleSelectCountry(country)}33          >34            <View style={styles.countryInfo}>35              <Text36                style={[37                  styles.countryName,38                  {39                    color: isSelected ? colors.tint : colors.text,40                    fontWeight: isSelected ? "600" : "400",41                  },42                ]}43              >44                {country.countryName}45              </Text>46              <Text style={[styles.currencyCode, { color: colors.icon }]}>47                {country.currencyCode.toUpperCase()}48              </Text>49            </View>50            {isSelected && (51              <Text style={{ color: colors.tint, fontSize: 18 }}></Text>52            )}53          </TouchableOpacity>54        )55      })56    )}57  </ScrollView>58)

You define the handleSelectCountry function that is called when a user selects a country. It updates the selected region and country using the setSelectedRegion function from the context, and calls the onRegionChange callback if provided.

You also define the isCountrySelected function that checks if a given country is currently selected.

Finally, you render the countries in a scrollable view. For each country, you create a touchable item that displays the country name and currency code. The selected country is highlighted, and a checkmark is shown next to it.

Add Drawer Content Component#

Next, you'll create a custom drawer content component that includes the region selector in the app's navigation drawer.

Create the file components/drawer-content.tsx with the following content:

components/drawer-content.tsx
1import { DrawerContentComponentProps, DrawerContentScrollView } from "@react-navigation/drawer"2import React from "react"3import { StyleSheet, View } from "react-native"4import { RegionSelector } from "./region-selector"5
6export function DrawerContent(props: DrawerContentComponentProps) {7  return (8    <DrawerContentScrollView {...props}>9      <View style={styles.container}>10        <RegionSelector onRegionChange={() => props.navigation.closeDrawer()} />11      </View>12    </DrawerContentScrollView>13  )14}15
16const styles = StyleSheet.create({17  container: {18    flex: 1,19  },20})

The DrawerContent component receives the drawer navigation props. You render the RegionSelector component and pass an onRegionChange callback that closes the drawer when a region is selected.

Add Drawer Navigation#

Finally, you'll update the app's navigation to wrap the main screens in a drawer navigator that uses the custom drawer content.

If you have an app/(tabs) directory for tab navigation, remove it for now. You'll add it later inside the drawer navigator.

Then, create the directory app/(drawer) which will contain the drawer navigation setup.

Next, create the file app/(drawer)/_layout.tsx with the following content:

app/(drawer)/_layout.tsx
1import { Drawer } from "expo-router/drawer"2
3import { DrawerContent } from "@/components/drawer-content"4
5export default function DrawerLayout() {6  return (7    <Drawer8      drawerContent={(props) => <DrawerContent {...props} />}9      screenOptions={{10        headerShown: false,11        drawerPosition: "left",12      }}13    >14      {/* TODO add tabs screens */}15    </Drawer>16  )17}

The DrawerLayout component sets up the drawer navigator using Expo Router's Drawer component.

The custom drawer content is shown on the left side of the screen, and the header is hidden.

Later, you'll add the tab screens inside the drawer navigator.

Next, replace the content of the file app/_layout.tsx with the following:

app/_layout.tsx
1import { useColorScheme } from "@/hooks/use-color-scheme"2import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native"3import { Stack } from "expo-router"4import { StatusBar } from "expo-status-bar"5import { GestureHandlerRootView } from "react-native-gesture-handler"6import "react-native-reanimated"7import { RegionProvider } from "@/context/region-context"8
9export default function RootLayout() {10  const colorScheme = useColorScheme()11
12  return (13    <GestureHandlerRootView style={{ flex: 1 }}>14      <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>15        <RegionProvider>16          <Stack screenOptions={{ headerShown: false }}>17            <Stack.Screen name="(drawer)" options={{ headerShown: false }} />18            {/* TODO: Add checkout and order confirmation screens */}19          </Stack>20          <StatusBar style="auto" />21        </RegionProvider>22      </ThemeProvider>23    </GestureHandlerRootView>24  )25}

The key changes are:

  1. Wrap the app in the RegionProvider to provide the region context to all components.
  2. Set the initial screen to the (drawer) layout, which contains the drawer navigator.

Later, you'll add the checkout and order confirmation screens to the stack navigator.

You'll test the region selector after implementing the main screens of the app in the next steps.


Step 7: Create Cart Context#

In this step, you'll create a cart context to manage the customer's cart state throughout the app. The cart context will allow you to create, manage, and complete the cart.

Create Cart Context#

Create the file context/cart-context.tsx with the following content:

context/cart-context.tsx
1import { sdk } from "@/lib/sdk"2import { FetchError } from "@medusajs/js-sdk"3import type { HttpTypes } from "@medusajs/types"4import AsyncStorage from "@react-native-async-storage/async-storage"5import React, { createContext, ReactNode, useCallback, useContext, useEffect, useState } from "react"6import { useRegion } from "./region-context"7
8interface CartContextType {9  cart: HttpTypes.StoreCart | null;10  addToCart: (variantId: string, quantity: number) => Promise<void>;11  updateItemQuantity: (itemId: string, quantity: number) => Promise<void>;12  removeItem: (itemId: string) => Promise<void>;13  refreshCart: () => Promise<void>;14  clearCart: () => Promise<void>;15  loading: boolean;16  error: string | null;17}18
19const CartContext = createContext<CartContextType | undefined>(undefined)20
21const CART_STORAGE_KEY = "cart_id"22
23export function CartProvider({ children }: { children: ReactNode }) {24  const [cart, setCart] = useState<HttpTypes.StoreCart | null>(null)25  const [loading, setLoading] = useState(false)26  const [error, setError] = useState<string | null>(null)27  const { selectedRegion } = useRegion()28
29  // TODO load cart30}31
32// TODO add useCart hook

You define the CartContext with the following properties that child components can use:

  • cart: The current cart object.
  • addToCart: A function to add an item to the cart.
  • updateItemQuantity: A function to update the quantity of an item in the cart.
  • removeItem: A function to remove an item from the cart.
  • refreshCart: A function to refresh the cart data from the backend.
  • clearCart: A function to clear the cart, which is useful after checkout.
  • loading: A boolean indicating whether a cart operation is in progress.
  • error: An error message if a cart operation fails.

You also define the CartProvider component that will wrap the app and provide the cart context to its children.

Next, you'll implement the logic to load the cart from the Medusa backend. Replace the // TODO load cart comment with the following:

context/cart-context.tsx
1const loadCart = useCallback(async () => {2  if (!selectedRegion) {return null}3
4  try {5    setLoading(true)6    setError(null)7
8    const savedCartId = await AsyncStorage.getItem(CART_STORAGE_KEY)9
10    if (savedCartId) {11      try {12        const { cart: fetchedCart } = await sdk.store.cart.retrieve(savedCartId, {13          fields: "+items.*",14        })15        16        setCart(fetchedCart)17        return fetchedCart18      } catch {19        // Cart not found or invalid, remove from storage20        await AsyncStorage.removeItem(CART_STORAGE_KEY)21      }22    }23
24    // Create new cart for current region25    const { cart: newCart } = await sdk.store.cart.create({26      region_id: selectedRegion.id,27    }, {28      fields: "+items.*",29    })30    setCart(newCart)31    await AsyncStorage.setItem(CART_STORAGE_KEY, newCart.id)32    return newCart33  } catch (err) {34    setError(`Failed to load cart: ${err instanceof FetchError ? err.message : String(err)}`)35    return null36  } finally {37    setLoading(false)38  }39}, [selectedRegion])40
41// Load cart on mount42useEffect(() => {43  loadCart()44}, [loadCart])45
46// TODO handle region update

The loadCart function creates or retrieves the saved cart from the Medusa backend using the JS SDK. This function runs when the component mounts using the useEffect hook.

Next, you'll implement the logic to handle region updates, as the cart's region must match the selected region.

Replace the // TODO handle region update comment with the following:

context/cart-context.tsx
1useEffect(() => {2  const updateCartRegion = async () => {3    if (!cart || !selectedRegion || cart.region_id === selectedRegion.id) {4      return5    }6
7    try {8      setLoading(true)9      const { cart: updatedCart } = await sdk.store.cart.update(cart.id, {10        region_id: selectedRegion.id,11      }, {12        fields: "+items.*",13      })14      setCart(updatedCart)15    } catch (err) {16      setError(`Failed to update cart region: ${err instanceof FetchError ? err.message : String(err)}`)17    } finally {18      setLoading(false)19    }20  }21
22  updateCartRegion()23}, [selectedRegion])24
25// TODO implement cart operations

You add an effect that runs whenever the selected region changes. If the cart's region doesn't match the selected region, it updates the cart's region using the JS SDK.

Next, you'll implement the cart operations that you defined in the context type. Replace the // TODO implement cart operations comment with the following:

context/cart-context.tsx
1const addToCart = async (variantId: string, quantity: number) => {2  let currentCart = cart3  4  if (!currentCart) {5    currentCart = await loadCart()6    if (!currentCart) {throw new Error("Could not create cart")}7  }8
9  try {10    setLoading(true)11    setError(null)12
13    const { cart: updatedCart } = await sdk.store.cart.createLineItem(currentCart.id, {14      variant_id: variantId,15      quantity,16    }, {17      fields: "+items.*",18    })19    setCart(updatedCart)20  } catch (err) {21    setError(`Failed to add item to cart: ${err instanceof FetchError ? err.message : String(err)}`)22    throw err23  } finally {24    setLoading(false)25  }26}27
28const updateItemQuantity = async (itemId: string, quantity: number) => {29  if (!cart) {return}30
31  try {32    setLoading(true)33    setError(null)34
35    const { cart: updatedCart } = await sdk.store.cart.updateLineItem(36      cart.id,37      itemId,38      { quantity },39      {40        fields: "+items.*",41      }42    )43    setCart(updatedCart)44  } catch (err) {45    setError(`Failed to update quantity: ${err instanceof FetchError ? err.message : String(err)}`)46    throw err47  } finally {48    setLoading(false)49  }50}51
52const removeItem = async (itemId: string) => {53  if (!cart) {return}54
55  try {56    setLoading(true)57    setError(null)58
59    const { parent: updatedCart } = await sdk.store.cart.deleteLineItem(cart.id, itemId, {60      fields: "+items.*",61    })62    setCart(updatedCart!)63  } catch (err) {64    setError(`Failed to remove item: ${err instanceof FetchError ? err.message : String(err)}`)65    throw err66  } finally {67    setLoading(false)68  }69}70
71const refreshCart = async () => {72  if (!cart) {return}73
74  try {75    const { cart: updatedCart } = await sdk.store.cart.retrieve(cart.id, {76      fields: "+items.*",77    })78    setCart(updatedCart)79  } catch (err) {80    setError(`Failed to refresh cart: ${err instanceof FetchError ? err.message : String(err)}`)81  }82}83
84const clearCart = async () => {85  setCart(null)86  await AsyncStorage.removeItem(CART_STORAGE_KEY)87  // Create a new cart88  if (selectedRegion) {89    const { cart: newCart } = await sdk.store.cart.create({90      region_id: selectedRegion.id,91    }, {92      fields: "+items.*",93    })94    setCart(newCart)95    await AsyncStorage.setItem(CART_STORAGE_KEY, newCart.id)96  }97}98
99return (100  <CartContext.Provider101    value={{102      cart,103      addToCart,104      updateItemQuantity,105      removeItem,106      refreshCart,107      clearCart,108      loading,109      error,110    }}111  >112    {children}113  </CartContext.Provider>114)

You define the following cart operation functions:

  • addToCart: Adds an item to the cart by creating a line item.
  • updateItemQuantity: Updates the quantity of an item in the cart.
  • removeItem: Removes an item from the cart.
  • refreshCart: Refreshes the cart data from the backend.
  • clearCart: Clears the cart and creates a new one.

You also add a return statement providing the context values to child components using the CartContext.Provider.

Finally, you'll create a custom hook in the same file to access the cart context easily. Replace the // TODO add useCart hook comment with the following:

context/cart-context.tsx
1export function useCart() {2  const context = useContext(CartContext)3  if (!context) {4    throw new Error("useCart must be used within a CartProvider")5  }6  return context7}

The useCart hook retrieves the context value using useContext. It throws an error if used outside of a CartProvider.

Wrap App in Cart Provider#

Next, you'll wrap the app in the CartProvider to provide the cart context to all components.

In app/_layout.tsx, add the following import at the top of the file:

app/_layout.tsx
import { CartProvider } from "@/context/cart-context"

Then, in the RootLayout's return statement, add the CartProvider as a child of the RegionProvider and a parent of the Stack component:

app/_layout.tsx
1return (2  <GestureHandlerRootView style={{ flex: 1 }}>3    <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>4      <RegionProvider>5        <CartProvider>6          <Stack screenOptions={{ headerShown: false }}>7            <Stack.Screen name="(drawer)" options={{ headerShown: false }} />8          </Stack>9          <StatusBar style="auto" />10        </CartProvider>11      </RegionProvider>12    </ThemeProvider>13  </GestureHandlerRootView>14)

Child components can now access the cart context using the useCart hook.


Step 8: Create Home Screen#

In this step, you'll create the home screen of the app. It will display a hero image with a list of products retrieved from the Medusa backend.

To implement the home screen, you'll create:

  1. Necessary utilities and components for the home screen.
  2. The home screen and navigation setup.

Add Price Formatting Utility#

First, you'll create a utility function to format prices based on the selected region's currency.

Create the file lib/format-price.ts with the following content:

lib/format-price.ts
1/**2 * Format a price amount with currency code3 * Note: Medusa stores prices in major units (e.g., dollars, euros)4 * so no conversion is needed5 */6export function formatPrice(7  amount: number | undefined,8  currencyCode: string | undefined9): string {10  if (amount === undefined || !currencyCode) {11    return "N/A"12  }13
14  return new Intl.NumberFormat("en-US", {15    style: "currency",16    currency: currencyCode.toUpperCase(),17  }).format(amount)18}

The formatPrice function takes an amount and a currency code as parameters. It formats the amount using the Intl.NumberFormat API to display it in the appropriate currency format.

Add Loading Component#

Next, you'll create a loading component that can be reused across the app to indicate loading states.

Create the file components/loading.tsx with the following content:

components/loading.tsx
1import { Colors } from "@/constants/theme"2import { useColorScheme } from "@/hooks/use-color-scheme"3import React from "react"4import { ActivityIndicator, StyleSheet, Text, View } from "react-native"5
6interface LoadingProps {7  message?: string;8}9
10export function Loading({ message }: LoadingProps) {11  const colorScheme = useColorScheme()12  const colors = Colors[colorScheme ?? "light"]13
14  return (15    <View style={styles.container}>16      <ActivityIndicator size="large" color={colors.tint} />17      {message && (18        <Text style={[styles.message, { color: colors.text }]}>{message}</Text>19      )}20    </View>21  )22}23
24const styles = StyleSheet.create({25  container: {26    flex: 1,27    justifyContent: "center",28    alignItems: "center",29    padding: 20,30  },31  message: {32    marginTop: 12,33    fontSize: 16,34  },35})

The Loading component displays a spinner and an optional message.

Add Product Card Component#

Next, you'll create a product card component to display individual products on the home screen.

Create the file components/product-card.tsx with the following content:

components/product-card.tsx
1import { Colors } from "@/constants/theme"2import { useRegion } from "@/context/region-context"3import { useColorScheme } from "@/hooks/use-color-scheme"4import { formatPrice } from "@/lib/format-price"5import type { HttpTypes } from "@medusajs/types"6import { Image } from "expo-image"7import { useRouter } from "expo-router"8import React from "react"9import { StyleSheet, Text, TouchableOpacity, View } from "react-native"10
11interface ProductCardProps {12  product: HttpTypes.StoreProduct;13}14
15export const ProductCard = React.memo(function ProductCard({ product }: ProductCardProps) {16  const router = useRouter()17  const colorScheme = useColorScheme()18  const colors = Colors[colorScheme ?? "light"]19  const { selectedRegion } = useRegion()20
21  const thumbnail = product.thumbnail || product.images?.[0]?.url22  const variant = product.variants?.[0]23  24  // Get price from calculated_price.calculated_amount25  const priceAmount = variant?.calculated_price?.calculated_amount || 026  27  // Use selected region's currency code28  const currencyCode = selectedRegion?.currency_code29
30  return (31    <TouchableOpacity32      style={[styles.card, { backgroundColor: colors.background }]}33      onPress={() => router.push({34        pathname: `/(home)/product/${product.id}` as any,35        params: { title: product.title },36      })}37      activeOpacity={0.7}38    >39      <Image40        source={{ uri: thumbnail || "https://via.placeholder.com/200" }}41        style={[styles.image, { backgroundColor: colors.imagePlaceholder }]}42        contentFit="cover"43      />44      <View style={styles.content}>45        <Text46          style={[styles.title, { color: colors.text }]}47          numberOfLines={2}48        >49          {product.title}50        </Text>51        <View style={styles.priceRow}>52          <Text style={[styles.price, { color: colors.tint }]}>53            {formatPrice(priceAmount, currencyCode)}54          </Text>55        </View>56      </View>57    </TouchableOpacity>58  )59})60

The ProductCard component receives a product as a prop.

It displays the product's thumbnail image, title, and price formatted using the formatPrice utility. When the card is pressed, it navigates to the product detail screen that you'll add later.

Add Home Screen#

Next, you'll add the home screen that displays a hero image and a list of products.

Your app will have a tab-based navigation structure, with a tab for the home screen, and another you'll add later for the cart.

So, to set up the tab navigation within the drawer navigator, create the directory app/(drawer)/(tabs).

Then, to create the home screen, create the file app/(drawer)/(tabs)/(home)/index.tsx with the following content:

app/(drawer)/(tabs)/(home)/index.tsx
1import { Loading } from "@/components/loading"2import { ProductCard } from "@/components/product-card"3import { Colors } from "@/constants/theme"4import { useRegion } from "@/context/region-context"5import { useColorScheme } from "@/hooks/use-color-scheme"6import { sdk } from "@/lib/sdk"7import type { HttpTypes } from "@medusajs/types"8import { Image } from "expo-image"9import React, { useCallback, useEffect, useState } from "react"10import { FlatList, RefreshControl, StyleSheet, Text, View } from "react-native"11
12export default function HomeScreen() {13  const colorScheme = useColorScheme()14  const colors = Colors[colorScheme ?? "light"]15  const { selectedRegion } = useRegion()16  17  const [products, setProducts] = useState<HttpTypes.StoreProduct[]>([])18  const [loading, setLoading] = useState(true)19  const [refreshing, setRefreshing] = useState(false)20  const [error, setError] = useState<string | null>(null)21
22  const fetchProducts = useCallback(async () => {23    try {24      setLoading(true)25      setError(null)26      27      const { products: fetchedProducts } = await sdk.store.product.list({28        region_id: selectedRegion?.id,29        fields: "*variants.calculated_price,+variants.inventory_quantity",30      })31      32      setProducts(fetchedProducts)33    } catch (err) {34      console.error("Failed to fetch products:", err)35      setError("Failed to load products. Please try again.")36    } finally {37      setLoading(false)38      setRefreshing(false)39    }40  }, [selectedRegion])41
42  useEffect(() => {43    if (selectedRegion) {44      fetchProducts()45    }46  }, [selectedRegion, fetchProducts])47
48  const onRefresh = () => {49    setRefreshing(true)50    fetchProducts()51  }52
53  // TODO add return statement54}55
56const styles = StyleSheet.create({57  container: {58    flex: 1,

The home screen component fetches products from the Medusa backend based on the selected region. It manages loading, refreshing, and error states.

Next, you'll add the return statement to render the home screen UI. Replace the // TODO add return statement comment with the following:

app/(drawer)/(tabs)/(home)/index.tsx
1if (loading) {2  return <Loading message="Loading products..." />3}4
5if (error) {6  return (7    <View style={[styles.centerContainer, { backgroundColor: colors.background }]}>8      <Text style={[styles.errorText, { color: colors.text }]}>{error}</Text>9    </View>10  )11}12
13return (14  <View style={[styles.container, { backgroundColor: colors.background }]}>15    <FlatList16      data={products}17      keyExtractor={(item) => item.id}18      numColumns={2}19      columnWrapperStyle={styles.row}20      initialNumToRender={6}21      maxToRenderPerBatch={6}22      windowSize={5}23      removeClippedSubviews={true}24      ListHeaderComponent={25        <View style={styles.header}>26          <Image27            source={{ uri: "https://images.unsplash.com/photo-1600185365483-26d7a4cc7519?w=800" }}28            style={[styles.banner, { backgroundColor: colors.imagePlaceholder }]}29            contentFit="cover"30          />31          <Text style={[styles.sectionTitle, { color: colors.text }]}>32            Latest Products33          </Text>34        </View>35      }36      renderItem={({ item }) => <ProductCard product={item} />}37      contentContainerStyle={styles.listContent}38      refreshControl={39        <RefreshControl40          refreshing={refreshing}41          onRefresh={onRefresh}42          tintColor={colors.tint}43        />44      }45      ListEmptyComponent={46        <View style={styles.emptyContainer}>47          <Text style={[styles.emptyText, { color: colors.text }]}>48            No products available49          </Text>50        </View>51      }52    />53  </View>54)

You handle three main states in the return statement:

  1. Loading State: If the products are still loading, you display the Loading component with a message.
  2. Error State: If there was an error fetching the products, you display an error message.
  3. Success State: If the products are successfully fetched, you render a FlatList to display the products in a grid layout. The list includes a header with a hero image and a section title.

You also configure the FlatList to support pull-to-refresh functionality and handle empty states.

Next, you'll add stack navigation for the home screen, which will later allow you to add a product detail screen.

Create the file app/(drawer)/(tabs)/(home)/_layout.tsx with the following content:

app/(drawer)/(tabs)/(home)/_layout.tsx
1import { useColorScheme } from "@/hooks/use-color-scheme"2import { DrawerActions } from "@react-navigation/native"3import { Stack, useNavigation } from "expo-router"4import React from "react"5import { TouchableOpacity } from "react-native"6
7import { IconSymbol } from "@/components/ui/icon-symbol"8import { Colors } from "@/constants/theme"9
10export default function HomeStackLayout() {11  const colorScheme = useColorScheme()12  const navigation = useNavigation()13  const colors = Colors[colorScheme ?? "light"]14
15  return (16    <Stack17      screenOptions={{18        headerShown: true,19      }}20    >21      <Stack.Screen 22        name="index"23        options={{24          title: "Medusa Store",25          headerLeft: () => (26            <TouchableOpacity27              onPress={() => navigation.dispatch(DrawerActions.openDrawer())}28              style={{ height: 36, width: 36, display: "flex", alignItems: "center", justifyContent: "center" }}29            >30              <IconSymbol size={28} name="line.3.horizontal" color={colors.icon} />31            </TouchableOpacity>32          ),33        }}34      />35      {/* TODO add product details screen */}36    </Stack>37  )38}

The HomeStackLayout component sets up a stack navigator for the home screen. It adds a header with a title and a menu button to open the drawer navigator.

Next, you'll add the tab navigation layout to include the home screen tab.

Create the file app/(drawer)/(tabs)/_layout.tsx with the following content:

app/(drawer)/(tabs)/_layout.tsx
1import { useColorScheme } from "@/hooks/use-color-scheme"2import { Tabs } from "expo-router"3import React from "react"4
5import { HapticTab } from "@/components/haptic-tab"6import { IconSymbol } from "@/components/ui/icon-symbol"7import { Colors } from "@/constants/theme"8
9export default function TabLayout() {10  const colorScheme = useColorScheme()11
12  return (13    <Tabs14      screenOptions={{15        tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,16        headerShown: true,17        tabBarButton: HapticTab,18      }}>19      <Tabs.Screen20        name="index"21        options={{22          href: null,23        }}24      />25      <Tabs.Screen26        name="(home)"27        options={{28          title: "Medusa Store",29          tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,30          headerShown: false, // Let the home stack manage its own headers31        }}32      />33      {/* TODO add cart tab */}34    </Tabs>35  )36}

The TabLayout component sets up a tab navigator using Expo Router's Tabs component. It adds a tab for the home screen with an icon and title.

An index tab is also specified to redirect to the home screen, ensuring it's always the default tab when the app opens.

So, create the file app/(drawer)/(tabs)/index.tsx with the following content:

app/(drawer)/(tabs)/index.tsx
1import { Redirect } from "expo-router"2import React from "react"3
4const MainScreen = () => {5  return <Redirect href="/(drawer)/(tabs)/(home)" />6}7
8export default MainScreen

The MainScreen component redirects to the home screen tab when accessed.

Note: If you get a type error regarding the href prop in the Redirect component, wait until the next time you run the project. The Expo Router types should update and the error should go away.

Add Tabs to Drawer Navigator#

Finally, you'll add the tab navigator to the drawer navigator.

In app/(drawer)/_layout.tsx, update the return statement to replace the TODO comment with the tabs screen:

app/(drawer)/_layout.tsx
1return (2  <Drawer3    drawerContent={(props) => <DrawerContent {...props} />}4    screenOptions={{5      headerShown: false,6      drawerPosition: "left",7    }}8  >9    <Drawer.Screen10      name="(tabs)"11      options={{12        drawerLabel: "Home",13        title: "Shop",14      }}15    />16  </Drawer>17)

The drawer navigator now includes the tab navigator as its main screen.

Test the Home Screen#

You can now test the home screen of your app.

There are different ways to run the React Native app, which you can learn about in Expo's documentation. The recommended way is to install the Expo Go app on your mobile device and run the app on it.

Before you run your app, start the Medusa backend by running the following command in the Medusa project directory:

Medusa Application
Terminal
npm run dev

Then, run the following command in your Expo project directory to start the Expo server:

Expo Application
Terminal
npm run start

In the terminal, you should see a QR code. Scan it using your device to open the app in Expo Go.

When the app opens, you'll see a home screen displaying a hero image and a list of products fetched from the Medusa application.

Note: Clicking on a product won't do anything yet, as you haven't implemented the product detail screen. You'll add it in the next step.

Home screen showing the hero image and list of products

If you click the menu icon in the top-left corner, the drawer navigator will open, allowing you to select the region using the region selector you implemented earlier.

Drawer navigator showing the region selector


Step 9: Create Product Detail Screen#

In this step, you'll create the product detail screen that displays detailed information about a selected product.

You'll create the necessary components and utilities first, then implement the product detail screen and navigation.

Add Button Component#

First, you'll create a button component that can be reused across the app.

Create the file components/ui/button.tsx with the following content:

components/ui/button.tsx
1import { Colors } from "@/constants/theme"2import { useColorScheme } from "@/hooks/use-color-scheme"3import React from "react"4import {5  ActivityIndicator,6  StyleSheet,7  Text,8  TouchableOpacity,9  TouchableOpacityProps,10} from "react-native"11
12interface ButtonProps extends TouchableOpacityProps {13  title: string;14  variant?: "primary" | "secondary";15  loading?: boolean;16}17
18export function Button({19  title,20  variant = "primary",21  loading = false,22  disabled,23  style,24  ...props25}: ButtonProps) {26  const colorScheme = useColorScheme()27  const colors = Colors[colorScheme ?? "light"]28
29  const isPrimary = variant === "primary"30  const isDisabled = disabled || loading31  32  // Primary button: white background with dark text in dark mode, tint background with white text in light mode33  const primaryBgColor = colorScheme === "dark" ? "#fff" : colors.tint34  const primaryTextColor = colorScheme === "dark" ? "#000" : "#fff"35
36  return (37    <TouchableOpacity38      style={[39        styles.button,40        isPrimary ? { backgroundColor: primaryBgColor } : styles.secondaryButton,41        isDisabled && styles.disabled,42        style,43      ]}44      disabled={isDisabled}45      {...props}46    >47      {loading ? (48        <ActivityIndicator color={isPrimary ? primaryTextColor : colors.tint} />49      ) : (50        <Text51          style={[52            styles.text,53            isPrimary ? { color: primaryTextColor } : { color: colors.tint },54          ]}55        >56          {title}57        </Text>58      )}59    </TouchableOpacity>60  )61}62
63const styles = StyleSheet.create({64  button: {65    paddingVertical: 14,

The Button component accepts props for the title, variant (primary or secondary), loading state, and other touchable opacity props.

Add Product Image Slider Component#

Next, you'll create a product image slider component to display multiple images of a product.

Create the file components/product-image-slider.tsx with the following content:

components/product-image-slider.tsx
1import { Colors } from "@/constants/theme"2import { useColorScheme } from "@/hooks/use-color-scheme"3import { Image } from "expo-image"4import React, { useRef, useState } from "react"5import { Dimensions, FlatList, StyleSheet, View } from "react-native"6
7const { width: SCREEN_WIDTH } = Dimensions.get("window")8
9interface ProductImageSliderProps {10  images: string[];11}12
13export function ProductImageSlider({ images }: ProductImageSliderProps) {14  const colorScheme = useColorScheme()15  const colors = Colors[colorScheme ?? "light"]16  const [currentImageIndex, setCurrentImageIndex] = useState(0)17  const imageListRef = useRef<FlatList>(null)18
19  const onViewableItemsChanged = useRef(({ viewableItems }: any) => {20    if (viewableItems.length > 0) {21      setCurrentImageIndex(viewableItems[0].index || 0)22    }23  }).current24
25  const viewabilityConfig = useRef({26    itemVisiblePercentThreshold: 50,27  }).current28
29  const renderImageItem = ({ item }: { item: string }) => (30    <View style={styles.imageSlide}>31      <Image32        source={{ uri: item }}33        style={[styles.image, { backgroundColor: colors.imagePlaceholder }]}34        contentFit="cover"35      />36    </View>37  )38
39  if (images.length === 0) {40    return null41  }42
43  return (44    <View style={styles.container}>45      <FlatList46        ref={imageListRef}47        data={images}48        renderItem={renderImageItem}49        keyExtractor={(item, index) => `image-${index}`}50        horizontal51        pagingEnabled52        showsHorizontalScrollIndicator={false}53        onViewableItemsChanged={onViewableItemsChanged}54        viewabilityConfig={viewabilityConfig}55      />56      {images.length > 1 && (57        <View style={styles.pagination}>58          {images.map((_, index) => (59            <View60              key={index}61              style={[62                styles.paginationDot,63                {64                  backgroundColor: currentImageIndex === index 65                    ? colors.tint 66                    : colors.icon + "40",67                },68              ]}69            />70          ))}71        </View>72      )}73    </View>74  )75}76
77const styles = StyleSheet.create({78  container: {79    position: "relative",

The ProductImageSlider component receives an array of image URLs as a prop and displays them in a horizontally scrollable FlatList. It also includes pagination dots to indicate the current image being viewed.

Add Product Skeleton Component#

Next, you'll create a skeleton component to display while the product details are loading. This improves the user experience by providing a visual placeholder.

Create the file components/product-skeleton.tsx with the following content:

components/product-skeleton.tsx
1import { Colors } from "@/constants/theme"2import { useColorScheme } from "@/hooks/use-color-scheme"3import React, { useEffect, useRef } from "react"4import { Animated, StyleSheet, View } from "react-native"5
6export function ProductSkeleton() {7  const colorScheme = useColorScheme()8  const colors = Colors[colorScheme ?? "light"]9  const shimmerAnim = useRef(new Animated.Value(0)).current10
11  useEffect(() => {12    Animated.loop(13      Animated.sequence([14        Animated.timing(shimmerAnim, {15          toValue: 1,16          duration: 1000,17          useNativeDriver: true,18        }),19        Animated.timing(shimmerAnim, {20          toValue: 0,21          duration: 1000,22          useNativeDriver: true,23        }),24      ])25    ).start()26  }, [shimmerAnim])27
28  const opacity = shimmerAnim.interpolate({29    inputRange: [0, 1],30    outputRange: [0.3, 0.7],31  })32
33  const skeletonColor = colorScheme === "dark" ? "#333" : "#e0e0e0"34
35  return (36    <View style={[styles.container, { backgroundColor: colors.background }]}>37      {/* Main Image Skeleton with Pagination Dots */}38      <View style={styles.imageContainer}>39        <Animated.View40          style={[41            styles.imageSkeleton,42            { backgroundColor: skeletonColor, opacity },43          ]}44        />45        <View style={styles.pagination}>46          {[1, 2, 3].map((i) => (47            <Animated.View48              key={i}49              style={[50                styles.paginationDot,51                { backgroundColor: skeletonColor, opacity },52              ]}53            />54          ))}55        </View>56      </View>57
58      <View style={styles.content}>59        {/* Title Skeleton */}60        <Animated.View61          style={[62            styles.titleSkeleton,63            { backgroundColor: skeletonColor, opacity },64          ]}65        />66
67        {/* Description Skeleton - 3 lines */}68        <View style={styles.descriptionContainer}>69          <Animated.View70            style={[71              styles.descriptionLine,72              { backgroundColor: skeletonColor, opacity, width: "100%" },73            ]}74          />75          <Animated.View76            style={[77              styles.descriptionLine,78              { backgroundColor: skeletonColor, opacity, width: "90%" },79            ]}80          />81          <Animated.View82            style={[83              styles.descriptionLine,84              { backgroundColor: skeletonColor, opacity, width: "70%" },85            ]}86          />87        </View>88
89        {/* Price Skeleton */}90        <Animated.View91          style={[92            styles.priceSkeleton,93            { backgroundColor: skeletonColor, opacity },94          ]}95        />96
97        {/* Options Skeleton */}98        <View style={styles.optionsContainer}>99          <Animated.View100            style={[101              styles.optionTitleSkeleton,102              { backgroundColor: skeletonColor, opacity },103            ]}104          />105          <View style={styles.optionButtons}>106            {[1, 2, 3].map((i) => (107              <Animated.View108                key={i}109                style={[110                  styles.optionButtonSkeleton,111                  { backgroundColor: skeletonColor, opacity },112                ]}113              />114            ))}115          </View>116        </View>117
118        {/* Quantity Skeleton */}119        <View style={styles.quantityContainer}>120          <Animated.View121            style={[122              styles.quantityLabelSkeleton,123              { backgroundColor: skeletonColor, opacity },124            ]}125          />126          <View style={styles.quantityControls}>127            <Animated.View128              style={[129                styles.quantityButtonSkeleton,130                { backgroundColor: skeletonColor, opacity },131              ]}132            />133            <Animated.View134              style={[135                styles.quantityValueSkeleton,136                { backgroundColor: skeletonColor, opacity },137              ]}138            />139            <Animated.View140              style={[141                styles.quantityButtonSkeleton,142                { backgroundColor: skeletonColor, opacity },143              ]}144            />145          </View>146        </View>147
148        {/* Add to Cart Button Skeleton */}149        <Animated.View150          style={[151            styles.buttonSkeleton,152            { backgroundColor: skeletonColor, opacity },153          ]}154        />155      </View>156    </View>157  )158}159
160const styles = StyleSheet.create({161  container: {162    flex: 1,

The ProductSkeleton component uses animated views to create a shimmering effect for various sections of the product detail screen, including the image, title, and buttons.

Add Toast Component#

Next, you'll create a toast component to display brief messages to the user, such as confirming that an item has been added to the cart.

Create the file components/ui/toast.tsx with the following content:

components/ui/toast.tsx
1import { useColorScheme } from "@/hooks/use-color-scheme"2import React, { useCallback, useEffect, useRef } from "react"3import { Animated, StyleSheet, Text, View } from "react-native"4
5interface ToastProps {6  message: string;7  visible: boolean;8  onHide: () => void;9  duration?: number;10  type?: "success" | "error" | "info";11}12
13export function Toast({ 14  message, 15  visible, 16  onHide, 17  duration = 3000,18  type = "success", 19}: ToastProps) {20  const colorScheme = useColorScheme()21  const opacity = useRef(new Animated.Value(0)).current22  const translateY = useRef(new Animated.Value(50)).current23
24  const hideToast = useCallback(() => {25    Animated.parallel([26      Animated.timing(opacity, {27        toValue: 0,28        duration: 300,29        useNativeDriver: true,30      }),31      Animated.timing(translateY, {32        toValue: 50,33        duration: 300,34        useNativeDriver: true,35      }),36    ]).start(() => {37      onHide()38    })39  }, [opacity, translateY, onHide])40
41  useEffect(() => {42    if (visible) {43      // Fade in and slide up44      Animated.parallel([45        Animated.timing(opacity, {46          toValue: 1,47          duration: 300,48          useNativeDriver: true,49        }),50        Animated.timing(translateY, {51          toValue: 0,52          duration: 300,53          useNativeDriver: true,54        }),55      ]).start()56
57      // Auto hide after duration58      const timer = setTimeout(() => {59        hideToast()60      }, duration)61
62      return () => clearTimeout(timer)63    }64  }, [visible, duration, opacity, translateY, hideToast])65
66  if (!visible) {67    return null68  }69
70  // Use inverted colors for minimal design: white in dark mode, black in light mode71  const backgroundColor = colorScheme === "dark" ? "#fff" : "#000"72  const textColor = colorScheme === "dark" ? "#000" : "#fff"73
74  return (75    <Animated.View76      style={[77        styles.container,78        {79          opacity,80          transform: [{ translateY }],81        },82      ]}83    >84      <View style={[styles.toast, { backgroundColor }]}>85        <Text style={[styles.message, { color: textColor }]}>{message}</Text>86      </View>87    </Animated.View>88  )89}90
91const styles = StyleSheet.create({92  container: {93    position: "absolute",

The Toast component accepts props for the message, visibility, hide callback, duration, and type. It uses animated values to create fade-in and slide-up effects when the toast appears and disappears.

Add Inventory Utility#

Next, you'll create a utility function to check if a product variant is in stock.

Create the file lib/inventory.ts with the following content:

lib/inventory.ts
1import type { HttpTypes } from "@medusajs/types"2
3/**4 * Check if a product variant is in stock5 * A variant is in stock if:6 * - manage_inventory is false (inventory tracking disabled), OR7 * - inventory_quantity is greater than 08 */9export function isVariantInStock(10  variant: HttpTypes.StoreProductVariant | undefined | null11): boolean {12  if (!variant) {13    return false14  }15
16  return variant.manage_inventory === false || (variant.inventory_quantity || 0) > 017}

The isVariantInStock function checks if a product variant is in stock. A variant is considered in stock if:

  • manage_inventory is false (inventory tracking is disabled), OR
  • inventory_quantity is greater than 0.

Implement Product Detail Screen#

You can now implement the product detail screen using the components and utilities you've created.

Create the file app/(drawer)/(tabs)/(home)/product/[id].tsx with the following content:

app/(drawer)/(tabs)/(home)/product/[id].tsx
1import { ProductImageSlider } from "@/components/product-image-slider"2import { ProductSkeleton } from "@/components/product-skeleton"3import { Button } from "@/components/ui/button"4import { Toast } from "@/components/ui/toast"5import { Colors } from "@/constants/theme"6import { useCart } from "@/context/cart-context"7import { useRegion } from "@/context/region-context"8import { useColorScheme } from "@/hooks/use-color-scheme"9import { formatPrice } from "@/lib/format-price"10import { isVariantInStock } from "@/lib/inventory"11import { sdk } from "@/lib/sdk"12import type { HttpTypes } from "@medusajs/types"13import { useLocalSearchParams, useNavigation } from "expo-router"14import React, { useCallback, useEffect, useMemo, useState } from "react"15import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"16
17export default function ProductDetailsScreen() {18  const { id, title } = useLocalSearchParams<{ id: string; title?: string }>()19  const colorScheme = useColorScheme()20  const colors = Colors[colorScheme ?? "light"]21  const { addToCart } = useCart()22  const { selectedRegion } = useRegion()23
24  const [product, setProduct] = useState<HttpTypes.StoreProduct | null>(null)25  const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({})26  const [quantity, setQuantity] = useState(1)27  const [loading, setLoading] = useState(true)28  const [addingToCart, setAddingToCart] = useState(false)29  const [error, setError] = useState<string | null>(null)30  const [toastVisible, setToastVisible] = useState(false)31  const [toastMessage, setToastMessage] = useState("")32  const navigation = useNavigation()33
34  // TODO fetch product35}36
37const styles = StyleSheet.create({38  container: {39    flex: 1,

The ProductDetailsScreen includes the following variables:

  • id: The product ID from the URL parameters.
  • colorScheme and colors: For theming based on the current color scheme.
  • addToCart: Function to add items to the cart from the cart context.
  • selectedRegion: The currently selected region from the region context.
  • product: The product details fetched from the backend.
  • selectedOptions: The currently selected product options, such as size or color.
  • quantity: The quantity of the product to add to the cart.
  • loading: Loading state for fetching product details.
  • addingToCart: Loading state for adding the product to the cart.
  • error: Error message if fetching product details fails.
  • toastVisible and toastMessage: For displaying toast messages.

Next, you'll implement the logic to fetch the product details based on the product ID. Replace the // TODO fetch product comment with the following:

app/(drawer)/(tabs)/(home)/product/[id].tsx
1const fetchProduct = useCallback(async () => {2  try {3    setLoading(true)4    setError(null)5
6    const { product: fetchedProduct } = await sdk.store.product.retrieve(id, {7      fields: "*variants.calculated_price,+variants.inventory_quantity",8      region_id: selectedRegion?.id,9    })10
11    setProduct(fetchedProduct)12    13    // Initialize selected options with first variant's option values14    if (fetchedProduct.variants && fetchedProduct.variants.length > 0) {15      const firstVariant = fetchedProduct.variants[0]16      const initialOptions: Record<string, string> = {}17      firstVariant.options?.forEach((optionValue) => {18        if (optionValue.option_id && optionValue.value) {19          initialOptions[optionValue.option_id] = optionValue.value20        }21      })22      setSelectedOptions(initialOptions)23    }24  } catch (err) {25    console.error("Failed to fetch product:", err)26    setError("Failed to load product. Please try again.")27  } finally {28    setLoading(false)29  }30}, [id, selectedRegion])31
32useEffect(() => {33  if (id && selectedRegion) {34    fetchProduct()35  }36}, [id, selectedRegion, fetchProduct])37
38// Update screen title immediately if passed as param, or when product is loaded39useEffect(() => {40  const productTitle = title || product?.title41  if (productTitle) {42    navigation.setOptions({43      title: productTitle,44    })45  }46}, [title, product, navigation])47
48// TODO select variant based on selected options

The fetchProduct function retrieves product details from the Medusa backend using the product ID. The region ID is passed as a query parameter to get prices specific to the selected region.

The selectedOptions state is initialized with the option values of the product's first variant.

Next, you'll implement the logic to select the appropriate product variant based on the currently selected options. Replace the // TODO select variant based on selected options comment with the following:

app/(drawer)/(tabs)/(home)/product/[id].tsx
1// Compute selected variant based on selected options2const selectedVariant = useMemo(() => {3  if (4    !product?.variants ||5    !product.options ||6    Object.keys(selectedOptions).length !== product.options?.length7  ) {8    return9  }10
11  return product.variants.find((variant) =>12    variant.options?.every(13      (optionValue) => optionValue.value === selectedOptions[optionValue.option_id!]14    )15  )16}, [selectedOptions, product])17
18// Check if we should show options UI19// Hide if there's only one option with one value (or all options have only one value each)20const shouldShowOptions = useMemo(() => {21  if (!product?.options || product.options.length === 0) {22    return false23  }24  // Show options only if at least one option has more than one value25  return product.options.some((option) => (option.values?.length ?? 0) > 1)26}, [product])27
28// Get all images from product29const images = useMemo(() => {30  const productImages = product?.images?.map((img) => img.url).filter(Boolean) || []31  // If no images, use thumbnail or fallback32  if (productImages.length === 0 && product?.thumbnail) {33    return [product.thumbnail]34  }35  return productImages.length > 0 ? productImages : []36}, [product])37
38// TODO handle add to cart

You define the following memoized values:

  • selectedVariant: The product variant that matches the currently selected options.
  • shouldShowOptions: A boolean indicating whether to show the options UI. The options UI is hidden if there's a single option with a single value.
  • images: An array of image URLs for the product, falling back to the thumbnail if no images are available.

Next, you'll implement the logic to handle adding the selected product variant to the cart. Replace the // TODO handle add to cart comment with the following:

app/(drawer)/(tabs)/(home)/product/[id].tsx
1const handleAddToCart = async () => {2  if (!selectedVariant) {3    setToastMessage(shouldShowOptions ? "Please select all options" : "Variant not available")4    setToastVisible(true)5    return6  }7
8  try {9    setAddingToCart(true)10    await addToCart(selectedVariant.id, quantity)11    setToastMessage("Product added to cart!")12    setToastVisible(true)13  } catch {14    setToastMessage("Failed to add product to cart")15    setToastVisible(true)16  } finally {17    setAddingToCart(false)18  }19}20
21// TODO render UI

The handleAddToCart function adds the selected variant to the cart using the addToCart function from the cart context. It displays appropriate toast messages based on the outcome.

Finally, you'll implement the UI rendering for the product detail screen. Replace the // TODO render UI comment with the following:

app/(drawer)/(tabs)/(home)/product/[id].tsx
1if (loading) {2  return <ProductSkeleton />3}4
5if (error || !product) {6  return (7    <View style={[styles.centerContainer, { backgroundColor: colors.background }]}>8      <Text style={[styles.errorText, { color: colors.text }]}>9        {error || "Product not found"}10      </Text>11    </View>12  )13}14
15// Get price from calculated_price.calculated_amount16const priceAmount = selectedVariant?.calculated_price?.calculated_amount || 017
18// Use selected region's currency code19const currencyCode = selectedRegion?.currency_code20
21// Check if selected variant is in stock22const isInStock = isVariantInStock(selectedVariant)23
24return (25  <ScrollView style={[styles.container, { backgroundColor: colors.background }]}>26    <ProductImageSlider images={images} />27
28    <View style={styles.content}>29      <Text style={[styles.title, { color: colors.text }]}>{product.title}</Text>30      31      {product.description && (32        <Text style={[styles.description, { color: colors.icon }]}>33          {product.description}34        </Text>35      )}36
37      <View style={styles.priceContainer}>38        <Text style={[styles.price, { color: colors.tint }]}>39          {formatPrice(priceAmount, currencyCode)}40        </Text>41        {!isInStock && (42          <View style={[styles.stockBadge, { backgroundColor: colors.error }]}>43            <Text style={styles.outOfStockText}>Out of Stock</Text>44          </View>45        )}46        {isInStock && selectedVariant?.inventory_quantity !== undefined && 47          selectedVariant.inventory_quantity! <= 10 && 48          selectedVariant.manage_inventory !== false && (49          <View style={[styles.stockBadge, { backgroundColor: colors.warning }]}>50            <Text style={styles.lowStockText}>51              Only {selectedVariant.inventory_quantity} left52            </Text>53          </View>54        )}55      </View>56
57      {shouldShowOptions && (58        <View style={styles.optionsSection}>59          {product.options?.map((option) => (60            <View key={option.id} style={styles.optionGroup}>61              <Text style={[styles.sectionTitle, { color: colors.text }]}>62                {option.title}63              </Text>64              <View style={styles.optionValues}>65                {option.values?.map((optionValue) => {66                  const isSelected = selectedOptions[option.id!] === optionValue.value67                  return (68                    <TouchableOpacity69                      key={optionValue.id}70                      style={[71                        styles.optionButton,72                        {73                          backgroundColor: isSelected74                            ? colors.tint + "20"75                            : "transparent",76                          borderColor: isSelected77                            ? colors.tint78                            : colors.icon + "30",79                        },80                      ]}81                      onPress={() => {82                        setSelectedOptions((prev) => ({83                          ...prev,84                          [option.id!]: optionValue.value!,85                        }))86                      }}87                    >88                      <Text89                        style={[90                          styles.optionText,91                          {92                            color: isSelected ? colors.tint : colors.text,93                            fontWeight: isSelected ? "600" : "400",94                          },95                        ]}96                      >97                        {optionValue.value}98                      </Text>99                    </TouchableOpacity>100                  )101                })}102              </View>103            </View>104          ))}105        </View>106      )}107
108      <View style={styles.quantitySection}>109        <Text style={[styles.sectionTitle, { color: colors.text }]}>Quantity</Text>110        <View style={styles.quantityControls}>111          <TouchableOpacity112            style={[styles.quantityButton, { borderColor: colors.icon }]}113            onPress={() => setQuantity(Math.max(1, quantity - 1))}114          >115            <Text style={[styles.quantityButtonText, { color: colors.text }]}>-</Text>116          </TouchableOpacity>117          <Text style={[styles.quantity, { color: colors.text }]}>{quantity}</Text>118          <TouchableOpacity119            style={[styles.quantityButton, { borderColor: colors.icon }]}120            onPress={() => setQuantity(quantity + 1)}121          >122            <Text style={[styles.quantityButtonText, { color: colors.text }]}>+</Text>123          </TouchableOpacity>124        </View>125      </View>126
127      <Button128        title={isInStock ? "Add to Cart" : "Out of Stock"}129        onPress={handleAddToCart}130        loading={addingToCart}131        disabled={!isInStock}132        style={styles.addButton}133      />134    </View>135    136    <Toast137      message={toastMessage}138      visible={toastVisible}139      onHide={() => setToastVisible(false)}140      type="success"141    />142  </ScrollView>143)

You handle three main states:

  1. Loading: Displays the ProductSkeleton while fetching product details.
  2. Error: Displays an error message if fetching fails or the product is not found.
  3. Success: Renders the product detail screen with the image slider, title, description, price, stock status, options, quantity selector, and add to cart button.

Add Product Detail Screen to Navigation#

Finally, you'll add the product detail screen to the home stack navigator.

In app/(drawer)/(tabs)/(home)/_layout.tsx, replace the TODO add product details screen comment with the following:

app/(drawer)/(tabs)/(home)/_layout.tsx
1<Stack.Screen 2  name="product/[id]"3  options={{4    title: "Product Details",5    presentation: "card",6    headerBackButtonDisplayMode: "minimal",7  }}8/>

A new screen is added to the stack navigator for the product detail screen, allowing users to navigate between the home screen and the product detail screen seamlessly.

Test Product Detail Screen#

To test out the product detail screen, run your Medusa application and Expo app.

Then, in the home screen, click on a product to view its details. This will open the product detail screen where you can view its images, select options, adjust quantity, and add it to the cart.

Product Detail Screen

You can try adding a product to the cart and see the toast notification confirming the action. You'll implement the cart screen in the next step.


Step 10: Create Cart Screen#

In this step, you'll create a cart screen where users can view the items they've added to their cart, adjust quantities, and proceed to checkout.

You'll create the necessary components and utilities first, then implement the cart screen and add it to the navigation.

Add Cart Item Component#

First, you'll create a cart item component to display individual items in the cart.

Create the file components/cart-item.tsx with the following content:

components/cart-item.tsx
1import { IconSymbol } from "@/components/ui/icon-symbol"2import { Colors } from "@/constants/theme"3import { useColorScheme } from "@/hooks/use-color-scheme"4import { formatPrice } from "@/lib/format-price"5import type { HttpTypes } from "@medusajs/types"6import { Image } from "expo-image"7import React from "react"8import { StyleSheet, Text, TouchableOpacity, View } from "react-native"9
10interface CartItemProps {11  item: HttpTypes.StoreCartLineItem;12  currencyCode?: string;13  onUpdateQuantity: (quantity: number) => void;14  onRemove: () => void;15}16
17export const CartItem = React.memo(function CartItem({ item, currencyCode, onUpdateQuantity, onRemove }: CartItemProps) {18  const colorScheme = useColorScheme()19  const colors = Colors[colorScheme ?? "light"]20
21  const thumbnail = item.thumbnail || item.variant?.product?.thumbnail22  const total = item.subtotal || 023
24  return (25    <View style={[styles.container, { borderBottomColor: colors.icon + "30" }]}>26      <Image27        source={{ uri: thumbnail || "https://via.placeholder.com/80" }}28        style={[styles.image, { backgroundColor: colors.imagePlaceholder }]}29        contentFit="cover"30      />31      <View style={styles.content}>32        <View style={styles.info}>33          <Text style={[styles.title, { color: colors.text }]} numberOfLines={2}>34            {item.product_title || item.title}35          </Text>36          {item.variant_title && (37            <Text style={[styles.variant, { color: colors.icon }]}>38              {item.variant_title}39            </Text>40          )}41          <Text style={[styles.price, { color: colors.text }]}>42            {formatPrice(total, currencyCode)}43          </Text>44        </View>45        <View style={styles.actions}>46          <View style={styles.quantityContainer}>47            <TouchableOpacity48              style={[styles.quantityButton, { borderColor: colors.icon }]}49              onPress={() => onUpdateQuantity(Math.max(1, item.quantity - 1))}50            >51              <Text style={[styles.quantityButtonText, { color: colors.text }]}>-</Text>52            </TouchableOpacity>53            <Text style={[styles.quantity, { color: colors.text }]}>54              {item.quantity}55            </Text>56            <TouchableOpacity57              style={[styles.quantityButton, { borderColor: colors.icon }]}58              onPress={() => onUpdateQuantity(item.quantity + 1)}59            >60              <Text style={[styles.quantityButtonText, { color: colors.text }]}>+</Text>61            </TouchableOpacity>62          </View>63          <TouchableOpacity onPress={onRemove} style={styles.removeButton}>64            <IconSymbol size={20} name="trash" color={colors.text} />65          </TouchableOpacity>66        </View>67      </View>68    </View>69  )70})71
72const styles = StyleSheet.create({73  container: {74    flexDirection: "row",

The CartItem component receives the following props:

  • item: The cart line item to display.
  • currencyCode: The currency code for formatting prices.
  • onUpdateQuantity: Callback to update the quantity of the item.
  • onRemove: Callback to remove the item from the cart.

In the component, you display the item's image, title, variant name, and price. You also provide buttons to adjust the quantity and remove the item from the cart.

Add Cart Screen#

Next, you'll implement the cart screen where users can view and manage their cart items.

As mentioned before, you'll have a tab for the cart screen. So, to create the cart screen, create the file app/(drawer)/(tabs)/(cart)/index.tsx with the following content:

app/(drawer)/(tabs)/(cart)/index.tsx
1import { CartItem } from "@/components/cart-item"2import { Loading } from "@/components/loading"3import { Button } from "@/components/ui/button"4import { Colors } from "@/constants/theme"5import { useCart } from "@/context/cart-context"6import { useColorScheme } from "@/hooks/use-color-scheme"7import { formatPrice } from "@/lib/format-price"8import { useRouter } from "expo-router"9import React from "react"10import { FlatList, StyleSheet, Text, View } from "react-native"11
12export default function CartScreen() {13  const colorScheme = useColorScheme()14  const colors = Colors[colorScheme ?? "light"]15  const router = useRouter()16  const { cart, updateItemQuantity, removeItem, loading } = useCart()17
18  const isEmpty = !cart?.items || cart.items.length === 019
20  if (loading && !cart) {21    return <Loading message="Loading cart..." />22  }23
24  if (isEmpty) {25    return (26      <View style={[styles.emptyContainer, { backgroundColor: colors.background }]}>27        <Text style={[styles.emptyTitle, { color: colors.text }]}>Your cart is empty</Text>28        <Text style={[styles.emptyText, { color: colors.icon }]}>29          Add some products to get started30        </Text>31        <Button32          title="Browse Products"33          onPress={() => router.push("/")}34          style={styles.browseButton}35        />36      </View>37    )38  }39
40  return (41    <View style={[styles.container, { backgroundColor: colors.background }]}>42      <FlatList43        data={cart.items}44        keyExtractor={(item) => item.id}45        renderItem={({ item }) => (46          <CartItem47            item={item}48            currencyCode={cart.currency_code}49            onUpdateQuantity={(quantity) => updateItemQuantity(item.id, quantity)}50            onRemove={() => removeItem(item.id)}51          />52        )}53        contentContainerStyle={styles.listContent}54      />55      56      <View style={[styles.footer, { backgroundColor: colors.background, borderTopColor: colors.icon + "30" }]}>57        <View style={styles.totals}>58          <View style={styles.totalRow}>59            <Text style={[styles.totalLabel, { color: colors.text }]}>Subtotal</Text>60            <Text style={[styles.totalValue, { color: colors.text }]}>61              {formatPrice(cart.item_subtotal, cart.currency_code)}62            </Text>63          </View>64          {cart.tax_total !== undefined && cart.tax_total > 0 && (65            <View style={styles.totalRow}>66              <Text style={[styles.totalLabel, { color: colors.text }]}>Tax</Text>67              <Text style={[styles.totalValue, { color: colors.text }]}>68                {formatPrice(cart.tax_total, cart.currency_code)}69              </Text>70            </View>71          )}72          {cart.shipping_total !== undefined && cart.shipping_total > 0 && (73            <View style={styles.totalRow}>74              <Text style={[styles.totalLabel, { color: colors.text }]}>Shipping</Text>75              <Text style={[styles.totalValue, { color: colors.text }]}>76                {formatPrice(cart.shipping_total, cart.currency_code)}77              </Text>78            </View>79          )}80          <View style={[styles.totalRow, styles.grandTotalRow, { borderTopColor: colors.border }]}>81            <Text style={[styles.grandTotalLabel, { color: colors.text }]}>Total</Text>82            <Text style={[styles.grandTotalValue, { color: colors.tint }]}>83              {formatPrice(cart.total, cart.currency_code)}84            </Text>85          </View>86        </View>87        <Button88          title="Proceed to Checkout"89          onPress={() =>  {90            // TODO navigate to checkout screen91          }}92          loading={loading}93        />94      </View>95    </View>96  )97}98
99const styles = StyleSheet.create({100  container: {101    flex: 1,

You define the CartScreen component that uses the useCart hook to access the cart state and actions. It handles three main states:

  • Loading: Displays a loading indicator while fetching the cart.
  • Empty Cart: Displays a message and a button to browse products if the cart is empty.
  • Cart with Items: Renders a list of cart items using the CartItem component, along with cart totals.

You also provide a button to proceed to checkout. Right now, the button does nothing. You'll change it to navigate to the checkout screen later.

Next, you'll add stack navigation for the cart screen.

Create the file app/(drawer)/(tabs)/(cart)/_layout.tsx with the following content:

app/(drawer)/(tabs)/(cart)/_layout.tsx
1import { useColorScheme } from "@/hooks/use-color-scheme"2import { DrawerActions } from "@react-navigation/native"3import { Stack, useNavigation } from "expo-router"4import React from "react"5import { TouchableOpacity } from "react-native"6
7import { IconSymbol } from "@/components/ui/icon-symbol"8import { Colors } from "@/constants/theme"9
10export default function CartStackLayout() {11  const colorScheme = useColorScheme()12  const navigation = useNavigation()13  const colors = Colors[colorScheme ?? "light"]14
15  return (16    <Stack17      screenOptions={{18        headerShown: true,19      }}20    >21      <Stack.Screen 22        name="index"23        options={{24          title: "Cart",25          headerLeft: () => (26            <TouchableOpacity27              onPress={() => navigation.dispatch(DrawerActions.openDrawer())}28              style={{ height: 36, width: 36, display: "flex", alignItems: "center", justifyContent: "center" }}29            >30              <IconSymbol size={28} name="line.3.horizontal" color={colors.icon} />31            </TouchableOpacity>32          ),33        }}34      />35    </Stack>36  )37}

You show the cart screen with a header that includes a button to open the drawer navigation.

Add Cart Tab to Navigation#

Finally, you'll add the cart tab to the main tab navigator.

In app/(drawer)/(tabs)/_layout.tsx, add the following import at the top of the file:

app/(drawer)/(tabs)/_layout.tsx
import { useCart } from "@/context/cart-context"

Then, in the TabLayout component, add the following before the return statement:

app/(drawer)/(tabs)/_layout.tsx
1const { cart } = useCart()2
3const itemCount = cart?.items?.length || 0

You access the cart from the cart context to get the number of items in the cart.

Finally, replace the // TODO add cart tab comment in the return statement with the following:

app/(drawer)/(tabs)/_layout.tsx
1<Tabs.Screen2  name="(cart)"3  options={{4    title: "Cart",5    tabBarIcon: ({ color }) => <IconSymbol size={28} name="cart.fill" color={color} />,6    tabBarBadge: itemCount > 0 ? itemCount : undefined,7    tabBarBadgeStyle: {8      backgroundColor: Colors[colorScheme ?? "light"].tint,9    },10    headerShown: false, // Let the cart stack manage its own headers11  }}12/>

You add a new tab to the tab navigator for the cart screen. The tab displays a badge with the number of items in the cart.

Test Cart Screen#

To test out the cart screen, run your Medusa application and Expo app.

Then, add some products to your cart from the product detail screen. You can open the cart by clicking its tab in the bottom navigation.

Cart screen with items in it

You can update an item's quantity or remove an item, and the cart will be updated accordingly.


Step 11: Create Checkout Screen#

In this step, you'll create a checkout screen where users enter their address, choose shipping and payment methods, and place their order.

The checkout screen will have three steps:

  1. Delivery: Enter shipping and billing addresses.
  2. Shipping: Choose a shipping method.
  3. Payment: Choose a payment method.

You'll implement first the three steps components, including their components and utilities, then the main checkout screen and navigation.

Add Address Form Component#

First, you'll create an address form component to collect shipping and billing addresses.

Create the file components/checkout/address-form.tsx with the following content:

components/checkout/address-form.tsx
1import { IconSymbol } from "@/components/ui/icon-symbol"2import { Colors } from "@/constants/theme"3import { useColorScheme } from "@/hooks/use-color-scheme"4import type { HttpTypes } from "@medusajs/types"5import { Picker } from "@react-native-picker/picker"6import React, { useRef, useState } from "react"7import {8  Modal,9  Platform,10  StyleSheet,11  Text,12  TextInput,13  TouchableOpacity,14  View,15} from "react-native"16
17interface AddressFormProps {18  firstName: string;19  lastName: string;20  address: string;21  city: string;22  postalCode: string;23  countryCode: string;24  phone: string;25  countries: HttpTypes.StoreRegionCountry[];26  onFirstNameChange: (value: string) => void;27  onLastNameChange: (value: string) => void;28  onAddressChange: (value: string) => void;29  onCityChange: (value: string) => void;30  onPostalCodeChange: (value: string) => void;31  onCountryCodeChange: (value: string) => void;32  onPhoneChange: (value: string) => void;33}34
35export function AddressForm({36  firstName,37  lastName,38  address,39  city,40  postalCode,41  countryCode,42  phone,43  countries,44  onFirstNameChange,45  onLastNameChange,46  onAddressChange,47  onCityChange,48  onPostalCodeChange,49  onCountryCodeChange,50  onPhoneChange,51}: AddressFormProps) {52  const colorScheme = useColorScheme()53  const colors = Colors[colorScheme ?? "light"]54  const [showPicker, setShowPicker] = useState(false)55  const [tempCountryCode, setTempCountryCode] = useState(countryCode)56
57  // Create refs for each input field58  const lastNameRef = useRef<TextInput>(null)59  const addressRef = useRef<TextInput>(null)60  const cityRef = useRef<TextInput>(null)61  const postalCodeRef = useRef<TextInput>(null)62  const phoneRef = useRef<TextInput>(null)63
64  const selectedCountry = countries.find(65    (c) => (c.iso_2 || c.id) === countryCode66  )67  const selectedCountryName = selectedCountry68    ? selectedCountry.display_name || selectedCountry.name || selectedCountry.iso_2 || selectedCountry.id69    : "Select Country"70
71  const handleDone = () => {72    onCountryCodeChange(tempCountryCode)73    setShowPicker(false)74    // Focus phone field after country selection75    setTimeout(() => phoneRef.current?.focus(), 100)76  }77
78  const handleCancel = () => {79    setTempCountryCode(countryCode)80    setShowPicker(false)81  }82
83  return (84    <>85      <View style={styles.row}>86        <TextInput87          style={[88            styles.input,89            styles.halfInput,90            { color: colors.text, borderColor: colors.icon + "30" },91          ]}92          placeholder="First Name"93          placeholderTextColor={colors.icon}94          value={firstName}95          onChangeText={onFirstNameChange}96          returnKeyType="next"97          onSubmitEditing={() => lastNameRef.current?.focus()}98        />99        <TextInput100          ref={lastNameRef}101          style={[102            styles.input,103            styles.halfInput,104            { color: colors.text, borderColor: colors.icon + "30" },105          ]}106          placeholder="Last Name"107          placeholderTextColor={colors.icon}108          value={lastName}109          onChangeText={onLastNameChange}110          returnKeyType="next"111          onSubmitEditing={() => addressRef.current?.focus()}112        />113      </View>114
115      <TextInput116        ref={addressRef}117        style={[styles.input, { color: colors.text, borderColor: colors.icon + "30" }]}118        placeholder="Address"119        placeholderTextColor={colors.icon}120        value={address}121        onChangeText={onAddressChange}122        returnKeyType="next"123        onSubmitEditing={() => cityRef.current?.focus()}124      />125
126      <View style={styles.row}>127        <TextInput128          ref={cityRef}129          style={[130            styles.input,131            styles.halfInput,132            { color: colors.text, borderColor: colors.icon + "30" },133          ]}134          placeholder="City"135          placeholderTextColor={colors.icon}136          value={city}137          onChangeText={onCityChange}138          returnKeyType="next"139          onSubmitEditing={() => postalCodeRef.current?.focus()}140        />141          <TextInput142            ref={postalCodeRef}143            style={[144              styles.input,145              styles.halfInput,146              { color: colors.text, borderColor: colors.icon + "30" },147            ]}148            placeholder="Postal Code"149            placeholderTextColor={colors.icon}150            value={postalCode}151            onChangeText={onPostalCodeChange}152            returnKeyType="next"153            onSubmitEditing={() => {154              setTempCountryCode(countryCode)155              setShowPicker(true)156            }}157          />158      </View>159
160      <TouchableOpacity161        style={[162          styles.input,163          styles.pickerButton,164          { borderColor: colors.icon + "30" },165        ]}166        onPress={() => {167          setTempCountryCode(countryCode)168          setShowPicker(true)169        }}170      >171        <Text style={[styles.pickerButtonText, { color: countryCode ? colors.text : colors.icon }]}>172          {selectedCountryName}173        </Text>174        <IconSymbol size={20} name="chevron.down" color={colors.icon} />175      </TouchableOpacity>176
177      <Modal178        visible={showPicker}179        transparent={true}180        animationType="none"181        onRequestClose={handleCancel}182      >183        <TouchableOpacity184          style={styles.modalOverlay}185          activeOpacity={1}186          onPress={handleCancel}187        >188          <TouchableOpacity189            activeOpacity={1}190            style={[styles.modalContent, { backgroundColor: colors.background }]}191            onPress={(e) => e.stopPropagation()}192          >193            <View style={[styles.modalHeader, { borderBottomColor: colors.icon + "30" }]}>194              <TouchableOpacity onPress={handleCancel}>195                <Text style={[styles.modalButton, { color: colors.tint }]}>Cancel</Text>196              </TouchableOpacity>197              <Text style={[styles.modalTitle, { color: colors.text }]}>Select Country</Text>198              <TouchableOpacity onPress={handleDone}>199                <Text style={[styles.modalButton, { color: colors.tint }]}>Done</Text>200              </TouchableOpacity>201            </View>202            <Picker203              selectedValue={tempCountryCode}204              onValueChange={(value) => {205                setTempCountryCode(value)206                if (Platform.OS === "android") {207                  onCountryCodeChange(value)208                  setShowPicker(false)209                  // Focus phone field after country selection on Android210                  setTimeout(() => phoneRef.current?.focus(), 100)211                }212              }}213              style={[styles.picker, Platform.OS === "android" && { color: colors.text }]}214              itemStyle={Platform.OS === "ios" ? styles.pickerItemIOS : undefined}215            >216              <Picker.Item label="Select Country" value="" enabled={false} />217              {countries.map((country) => {218                const code = country.iso_2 || country.id219                const name = country.display_name || country.name || country.iso_2 || country.id220                return <Picker.Item key={code} label={name} value={code} />221              })}222            </Picker>223          </TouchableOpacity>224        </TouchableOpacity>225      </Modal>226
227      <TextInput228        ref={phoneRef}229        style={[styles.input, { color: colors.text, borderColor: colors.icon + "30" }]}230        placeholder="Phone Number"231        placeholderTextColor={colors.icon}232        value={phone}233        onChangeText={onPhoneChange}234        keyboardType="phone-pad"235        returnKeyType="done"236      />237    </>238  )239}240
241const styles = StyleSheet.create({242  input: {243    borderWidth: 1,

The AddressForm component receives various props for the address fields and their change handlers. It also receives a list of countries to populate the country picker.

The component renders input fields for the address and a modal picker for selecting the country. It handles input focus and country selection appropriately.

Add Delivery Step Component#

Next, you'll create the delivery step component for the checkout process. It will use the AddressForm component to collect shipping and billing addresses.

Create the file components/checkout/delivery-step.tsx with the following content:

components/checkout/delivery-step.tsx
1import { AddressForm } from "@/components/checkout/address-form"2import { Button } from "@/components/ui/button"3import { Colors } from "@/constants/theme"4import { useRegion } from "@/context/region-context"5import { useColorScheme } from "@/hooks/use-color-scheme"6import React, { useEffect, useRef, useState } from "react"7import {8  Keyboard,9  KeyboardAvoidingView,10  Platform,11  ScrollView,12  StyleSheet,13  Switch,14  Text,15  TextInput,16  View,17} from "react-native"18
19interface Address {20  firstName: string;21  lastName: string;22  address: string;23  city: string;24  postalCode: string;25  countryCode: string;26  phone: string;27}28
29interface DeliveryStepProps {30  email: string;31  shippingAddress: Address;32  billingAddress: Address;33  useSameForBilling: boolean;34  loading: boolean;35  onEmailChange: (value: string) => void;36  onShippingAddressChange: (field: keyof Address, value: string) => void;37  onBillingAddressChange: (field: keyof Address, value: string) => void;38  onUseSameForBillingChange: (value: boolean) => void;39  onNext: () => void;40}41
42export function DeliveryStep({43  email,44  shippingAddress,45  billingAddress,46  useSameForBilling,47  loading,48  onEmailChange,49  onShippingAddressChange,50  onBillingAddressChange,51  onUseSameForBillingChange,52  onNext,53}: DeliveryStepProps) {54  const colorScheme = useColorScheme()55  const colors = Colors[colorScheme ?? "light"]56  const { selectedRegion } = useRegion()57  const scrollViewRef = useRef<ScrollView>(null)58  const [isKeyboardVisible, setKeyboardVisible] = useState(false)59
60  const countries = selectedRegion?.countries || []61
62  useEffect(() => {63    const keyboardWillShowListener = Keyboard.addListener(64      Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow",65      () => setKeyboardVisible(true)66    )67    const keyboardWillHideListener = Keyboard.addListener(68      Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide",69      () => setKeyboardVisible(false)70    )71
72    return () => {73      keyboardWillShowListener.remove()74      keyboardWillHideListener.remove()75    }76  }, [])77
78  return (79    <KeyboardAvoidingView 80      style={styles.container}81      behavior={Platform.OS === "ios" ? "padding" : "height"}82      keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}83    >84      <ScrollView85        ref={scrollViewRef}86        style={styles.scrollView}87        contentContainerStyle={[88          styles.scrollContent,89          isKeyboardVisible && styles.scrollContentKeyboard,90        ]}91        keyboardShouldPersistTaps="handled"92        keyboardDismissMode="on-drag"93        showsVerticalScrollIndicator={true}94        automaticallyAdjustKeyboardInsets={true}95      >96        <View style={styles.section}>97          <Text style={[styles.sectionTitle, { color: colors.text }]}>98            Contact Information99          </Text>100
101          <TextInput102            style={[styles.input, { color: colors.text, borderColor: colors.icon + "30" }]}103            placeholder="Email"104            placeholderTextColor={colors.icon}105            value={email}106            onChangeText={onEmailChange}107            keyboardType="email-address"108            autoCapitalize="none"109            returnKeyType="next"110          />111
112          <Text style={[styles.sectionTitle, { color: colors.text, marginTop: 20 }]}>113            Shipping Address114          </Text>115
116          <AddressForm117            firstName={shippingAddress.firstName}118            lastName={shippingAddress.lastName}119            address={shippingAddress.address}120            city={shippingAddress.city}121            postalCode={shippingAddress.postalCode}122            countryCode={shippingAddress.countryCode}123            phone={shippingAddress.phone}124            countries={countries}125            onFirstNameChange={(value) => onShippingAddressChange("firstName", value)}126            onLastNameChange={(value) => onShippingAddressChange("lastName", value)}127            onAddressChange={(value) => onShippingAddressChange("address", value)}128            onCityChange={(value) => onShippingAddressChange("city", value)}129            onPostalCodeChange={(value) => onShippingAddressChange("postalCode", value)}130            onCountryCodeChange={(value) => onShippingAddressChange("countryCode", value)}131            onPhoneChange={(value) => onShippingAddressChange("phone", value)}132          />133
134          <View style={styles.switchContainer}>135            <Text style={[styles.switchLabel, { color: colors.text }]}>136              Use same address for billing137            </Text>138            <Switch139              value={useSameForBilling}140              onValueChange={onUseSameForBillingChange}141            />142          </View>143
144          {!useSameForBilling && (145            <>146              <Text style={[styles.sectionTitle, { color: colors.text, marginTop: 20 }]}>147                Billing Address148              </Text>149
150              <AddressForm151                firstName={billingAddress.firstName}152                lastName={billingAddress.lastName}153                address={billingAddress.address}154                city={billingAddress.city}155                postalCode={billingAddress.postalCode}156                countryCode={billingAddress.countryCode}157                phone={billingAddress.phone}158                countries={countries}159                onFirstNameChange={(value) => onBillingAddressChange("firstName", value)}160                onLastNameChange={(value) => onBillingAddressChange("lastName", value)}161                onAddressChange={(value) => onBillingAddressChange("address", value)}162                onCityChange={(value) => onBillingAddressChange("city", value)}163                onPostalCodeChange={(value) => onBillingAddressChange("postalCode", value)}164                onCountryCodeChange={(value) => onBillingAddressChange("countryCode", value)}165                onPhoneChange={(value) => onBillingAddressChange("phone", value)}166              />167            </>168          )}169        </View>170
171        {/* Button moved inside ScrollView for consistent behavior */}172        <View style={[styles.buttonContainer, { backgroundColor: colors.background }]}>173          <Button174            title="Continue"175            onPress={onNext}176            loading={loading}177            style={styles.button}178          />179        </View>180      </ScrollView>181    </KeyboardAvoidingView>182  )183}184
185const styles = StyleSheet.create({186  container: {187    flex: 1,

The DeliveryStep component receives the following props:

  • email: The email address associated with the cart.
  • shippingAddress: The shipping address details of the cart.
  • billingAddress: The billing address details of the cart.
  • useSameForBilling: A boolean indicating whether to use the shipping address for billing.
  • loading: A boolean indicating whether the form is in a loading state.
  • onEmailChange: Callback to update the email address.
  • onShippingAddressChange: Callback to update the shipping address fields.
  • onBillingAddressChange: Callback to update the billing address fields.
  • onUseSameForBillingChange: Callback to toggle the use of the same address for billing.
  • onNext: Callback to proceed to the next step.

The component renders an email input field and uses the AddressForm component to collect shipping and billing addresses. A switch is provided to toggle whether to use the same address for billing.

Add Shipping Step Component#

Next, you'll create the shipping step component for the checkout process. This step allows users to select a shipping method.

Create the file components/checkout/shipping-step.tsx with the following content:

components/checkout/shipping-step.tsx
1import { Loading } from "@/components/loading"2import { Button } from "@/components/ui/button"3import { Colors } from "@/constants/theme"4import { useColorScheme } from "@/hooks/use-color-scheme"5import { formatPrice } from "@/lib/format-price"6import type { HttpTypes } from "@medusajs/types"7import React from "react"8import { StyleSheet, Text, TouchableOpacity, View } from "react-native"9
10interface ShippingStepProps {11  shippingOptions: HttpTypes.StoreCartShippingOption[];12  selectedShippingOption: string | null;13  currencyCode?: string;14  loading: boolean;15  onSelectOption: (optionId: string) => void;16  onBack: () => void;17  onNext: () => void;18}19
20export function ShippingStep({21  shippingOptions,22  selectedShippingOption,23  currencyCode,24  loading,25  onSelectOption,26  onBack,27  onNext,28}: ShippingStepProps) {29  const colorScheme = useColorScheme()30  const colors = Colors[colorScheme ?? "light"]31
32  return (33    <View style={styles.container}>34      <View style={styles.contentWrapper}>35        <View style={styles.section}>36      <Text style={[styles.sectionTitle, { color: colors.text }]}>37        Select Shipping Method38      </Text>39
40      {loading ? (41        <Loading />42      ) : shippingOptions.length === 0 ? (43        <Text style={[styles.emptyText, { color: colors.icon }]}>44          No shipping options available45        </Text>46      ) : (47        shippingOptions.map((option) => (48          <TouchableOpacity49            key={option.id}50            style={[51              styles.optionCard,52              {53                backgroundColor:54                  selectedShippingOption === option.id55                    ? colors.tint + "20"56                    : "transparent",57                borderColor:58                  selectedShippingOption === option.id59                    ? colors.tint60                    : colors.icon + "30",61              },62            ]}63            onPress={() => onSelectOption(option.id)}64          >65            <View style={styles.optionInfo}>66              <Text67                style={[68                  styles.optionTitle,69                  {70                    color:71                      selectedShippingOption === option.id72                        ? colors.tint73                        : colors.text,74                  },75                ]}76              >77                {option.name}78              </Text>79              <Text style={[styles.optionPrice, { color: colors.text }]}>80                {formatPrice(option.amount, currencyCode)}81              </Text>82            </View>83            {selectedShippingOption === option.id && (84              <Text style={{ color: colors.tint, fontSize: 20 }}></Text>85            )}86          </TouchableOpacity>87        ))88      )}89        </View>90      </View>91
92      <View style={[styles.buttonContainer, { backgroundColor: colors.background, borderTopColor: colors.icon + "30" }]}>93        <View style={styles.buttonRow}>94          <Button95            title="Back"96            variant="secondary"97            onPress={onBack}98            style={styles.halfButton}99          />100          <Button101            title="Continue"102            onPress={onNext}103            loading={loading}104            disabled={!selectedShippingOption}105            style={styles.halfButton}106          />107        </View>108      </View>109    </View>110  )111}112
113const styles = StyleSheet.create({114  container: {115    flex: 1,

The ShippingStep component receives the following props:

  • shippingOptions: An array of available shipping options.
  • selectedShippingOption: The ID of the currently selected shipping option.
  • currencyCode: The currency code for formatting prices.
  • loading: A boolean indicating whether the shipping options are being loaded.
  • onSelectOption: Callback to select a shipping option.
  • onBack: Callback to go back to the previous step.
  • onNext: Callback to proceed to the next step.

The component renders a list of shipping options with their names and prices. The selected option is highlighted.

Add Payment Provider Utility#

Before creating the payment step component, you'll add a utility function that maps payment provider IDs to their display information.

Create the file lib/payment-providers.ts with the following content:

lib/payment-providers.ts
1/**2 * Information about a payment provider for display purposes3 */4export interface PaymentProviderInfo {5  icon: string;6  title: string;7}8
9/**10 * Get display information for a payment provider based on its ID11 * Returns an icon name and formatted title for the payment provider12 */13export function getPaymentProviderInfo(providerId: string): PaymentProviderInfo {14  switch (providerId) {15    case "pp_system_default":16      return {17        icon: "creditcard",18        title: "Manual Payment",19      }20    default:21      return {22        icon: "creditcard",23        title: providerId.replace("pp_", "").replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()),24      }25  }26}

The getPaymentProviderInfo function takes a payment provider ID and returns an object containing an icon name and a formatted title for display purposes.

The function only handles Medusa's built-in manual payment provider pp_system_default. You can expand it later to include more providers that you integrate.

Add Payment Step Component#

You'll now create the payment step component for the checkout process. This step allows users to select a payment method, and shows the cart's summary before placing the order.

This step will not handle actual payment processing. Instead, it will just allow users to select a payment method and place the order. If you integrate real payment providers later, you'll need to handle their specific payment flows.

Create the file components/checkout/payment-step.tsx with the following content:

components/checkout/payment-step.tsx
1import { Loading } from "@/components/loading"2import { Button } from "@/components/ui/button"3import { IconSymbol } from "@/components/ui/icon-symbol"4import { Colors } from "@/constants/theme"5import { useColorScheme } from "@/hooks/use-color-scheme"6import { formatPrice } from "@/lib/format-price"7import { getPaymentProviderInfo } from "@/lib/payment-providers"8import type { HttpTypes } from "@medusajs/types"9import React from "react"10import { StyleSheet, Text, TouchableOpacity, View } from "react-native"11
12interface PaymentStepProps {13  cart: HttpTypes.StoreCart;14  paymentProviders: HttpTypes.StorePaymentProvider[];15  selectedPaymentProvider: string | null;16  loading: boolean;17  onSelectProvider: (providerId: string) => void;18  onBack: () => void;19  onPlaceOrder: () => void;20}21
22export function PaymentStep({23  cart,24  paymentProviders,25  selectedPaymentProvider,26  loading,27  onSelectProvider,28  onBack,29  onPlaceOrder,30}: PaymentStepProps) {31  const colorScheme = useColorScheme()32  const colors = Colors[colorScheme ?? "light"]33
34  return (35    <View style={styles.container}>36      <View style={styles.contentWrapper}>37        <View style={styles.section}>38      <Text style={[styles.sectionTitle, { color: colors.text }]}>39        Select Payment Method40      </Text>41
42      {loading ? (43        <Loading />44      ) : paymentProviders.length === 0 ? (45        <Text style={[styles.emptyText, { color: colors.icon }]}>46          No payment providers available47        </Text>48      ) : (49        paymentProviders.map((provider) => {50          const providerInfo = getPaymentProviderInfo(provider.id)51          const isSelected = selectedPaymentProvider === provider.id52          53          return (54            <TouchableOpacity55              key={provider.id}56              style={[57                styles.optionCard,58                {59                  backgroundColor: isSelected ? colors.tint + "20" : "transparent",60                  borderColor: isSelected ? colors.tint : colors.icon + "30",61                },62              ]}63              onPress={() => onSelectProvider(provider.id)}64            >65              <View style={styles.optionContent}>66                <IconSymbol67                  size={24}68                  name={providerInfo.icon as any}69                  color={isSelected ? colors.tint : colors.icon}70                />71                <Text72                  style={[73                    styles.optionTitle,74                    {75                      color: isSelected ? colors.tint : colors.text,76                    },77                  ]}78                >79                  {providerInfo.title}80                </Text>81              </View>82              {isSelected && (83                <Text style={{ color: colors.tint, fontSize: 20 }}></Text>84              )}85            </TouchableOpacity>86          )87        })88      )}89
90      <View style={[styles.summary, { borderColor: colors.icon + "30" }]}>91        <Text style={[styles.summaryTitle, { color: colors.text }]}>92          Order Summary93        </Text>94        <View style={styles.summaryRow}>95          <Text style={[styles.summaryLabel, { color: colors.text }]}>Subtotal</Text>96          <Text style={[styles.summaryValue, { color: colors.text }]}>97            {formatPrice(cart.item_subtotal || 0, cart.currency_code)}98          </Text>99        </View>100        <View style={styles.summaryRow}>101          <Text style={[styles.summaryLabel, { color: colors.text }]}>Discount</Text>102          <Text style={[styles.summaryValue, { color: colors.text }]}>103            {(cart.discount_total || 0) > 0 ? "-" : ""}{formatPrice(cart.discount_total || 0, cart.currency_code)}104          </Text>105        </View>106        <View style={styles.summaryRow}>107          <Text style={[styles.summaryLabel, { color: colors.text }]}>Shipping</Text>108          <Text style={[styles.summaryValue, { color: colors.text }]}>109            {formatPrice(cart.shipping_total || 0, cart.currency_code)}110          </Text>111        </View>112        <View style={styles.summaryRow}>113          <Text style={[styles.summaryLabel, { color: colors.text }]}>Tax</Text>114          <Text style={[styles.summaryValue, { color: colors.text }]}>115            {formatPrice(cart.tax_total || 0, cart.currency_code)}116          </Text>117        </View>118        <View style={[styles.summaryRow, styles.totalRow, { borderTopColor: colors.border }]}>119          <Text style={[styles.totalLabel, { color: colors.text }]}>Total</Text>120          <Text style={[styles.totalValue, { color: colors.tint }]}>121            {formatPrice(cart.total, cart.currency_code)}122          </Text>123        </View>124      </View>125        </View>126      </View>127
128      <View style={[styles.buttonContainer, { backgroundColor: colors.background, borderTopColor: colors.icon + "30" }]}>129        <View style={styles.buttonRow}>130          <Button131            title="Back"132            variant="secondary"133            onPress={onBack}134            style={styles.halfButton}135          />136          <Button137            title="Place Order"138            onPress={onPlaceOrder}139            loading={loading}140            disabled={!selectedPaymentProvider}141            style={styles.halfButton}142          />143        </View>144      </View>145    </View>146  )147}148
149const styles = StyleSheet.create({150  container: {151    flex: 1,

The PaymentStep component receives the following props:

  • cart: The current cart object containing pricing details.
  • paymentProviders: An array of available payment providers.
  • selectedPaymentProvider: The ID of the currently selected payment provider.
  • loading: A boolean indicating whether the payment providers are being loaded.
  • onSelectProvider: Callback to select a payment provider.
  • onBack: Callback to go back to the previous step.
  • onPlaceOrder: Callback to place the order.

The component renders a list of payment providers with their icons and titles. The selected provider is highlighted.

A summary of the cart's pricing details is displayed, including subtotal, discounts, shipping, tax, and total amount.

Add Checkout Screen Component#

Finally, you'll create the main checkout screen that manages the multi-step checkout process. It will use the previously created step components to guide users through entering their information, selecting shipping and payment methods, and placing their order.

Create the file app/checkout.tsx with the following content:

app/checkout.tsx
1import { DeliveryStep } from "@/components/checkout/delivery-step"2import { PaymentStep } from "@/components/checkout/payment-step"3import { ShippingStep } from "@/components/checkout/shipping-step"4import { Colors } from "@/constants/theme"5import { useCart } from "@/context/cart-context"6import { useColorScheme } from "@/hooks/use-color-scheme"7import { sdk } from "@/lib/sdk"8import type { HttpTypes } from "@medusajs/types"9import React, { useCallback, useEffect, useState } from "react"10import { Alert, StyleSheet, Text, View } from "react-native"11
12type CheckoutStep = "delivery" | "shipping" | "payment";13
14export default function CheckoutScreen() {15  const colorScheme = useColorScheme()16  const colors = Colors[colorScheme ?? "light"]17  const { cart, refreshCart } = useCart()18
19  const [currentStep, setCurrentStep] = useState<CheckoutStep>("delivery")20  const [loading, setLoading] = useState(false)21
22  // Contact & Address state23  const [email, setEmail] = useState("")24  const [shippingAddress, setShippingAddress] = useState({25    firstName: "",26    lastName: "",27    address: "",28    city: "",29    postalCode: "",30    countryCode: "",31    phone: "",32  })33  const [useSameForBilling, setUseSameForBilling] = useState(true)34  const [billingAddress, setBillingAddress] = useState({35    firstName: "",36    lastName: "",37    address: "",38    city: "",39    postalCode: "",40    countryCode: "",41    phone: "",42  })43
44  // Shipping step45  const [shippingOptions, setShippingOptions] = useState<HttpTypes.StoreCartShippingOption[]>([])46  const [selectedShippingOption, setSelectedShippingOption] = useState<string | null>(null)47
48  // Payment step49  const [paymentProviders, setPaymentProviders] = useState<HttpTypes.StorePaymentProvider[]>([])50  const [selectedPaymentProvider, setSelectedPaymentProvider] = useState<string | null>(null)51
52  // TODO update state when cart changes53}54
55const styles = StyleSheet.create({56  container: {57    flex: 1,

The CheckoutScreen component includes the following state variables:

  • currentStep: The current step in the checkout process.
  • loading: A boolean indicating whether an operation is in progress.
  • Contact and address fields for shipping and billing.
  • Shipping options and the selected shipping option.
  • Payment providers and the selected payment provider.

Next, you'll handle updating the state variables when the cart changes. Replace the // TODO update state when cart changes comment with the following:

app/checkout.tsx
1useEffect(() => {2  // Populate form with existing cart data or reset to empty values3  setEmail(cart?.email || "")4  setShippingAddress({5    firstName: cart?.shipping_address?.first_name || "",6    lastName: cart?.shipping_address?.last_name || "",7    address: cart?.shipping_address?.address_1 || "",8    city: cart?.shipping_address?.city || "",9    postalCode: cart?.shipping_address?.postal_code || "",10    countryCode: cart?.shipping_address?.country_code || "",11    phone: cart?.shipping_address?.phone || "",12  })13  14  // Billing address - check if different from shipping15  const hasDifferentBilling = cart?.billing_address && 16    (cart.billing_address.address_1 !== cart.shipping_address?.address_1 ||17      cart.billing_address.city !== cart.shipping_address?.city)18  19  setUseSameForBilling(!hasDifferentBilling)20  setBillingAddress({21    firstName: cart?.billing_address?.first_name || "",22    lastName: cart?.billing_address?.last_name || "",23    address: cart?.billing_address?.address_1 || "",24    city: cart?.billing_address?.city || "",25    postalCode: cart?.billing_address?.postal_code || "",26    countryCode: cart?.billing_address?.country_code || "",27    phone: cart?.billing_address?.phone || "",28  })29  30  // Reset selections when cart is null31  if (!cart) {32    setSelectedShippingOption(null)33    setSelectedPaymentProvider(null)34    setCurrentStep("delivery")35  }36}, [cart])37
38// TODO fetch shipping options and payment providers

A useEffect hook populates the form fields with existing cart data when the cart changes.

Next, you'll fetch the available shipping options and payment providers when the user reaches the respective steps. Replace the // TODO fetch shipping options and payment providers comment with the following:

app/checkout.tsx
1const fetchShippingOptions = useCallback(async () => {2  if (!cart) {return}3
4  try {5    setLoading(true)6    const { shipping_options } = await sdk.store.fulfillment.listCartOptions({7      cart_id: cart.id,8    })9    setShippingOptions(shipping_options || [])10  } catch (err) {11    console.error("Failed to fetch shipping options:", err)12    Alert.alert("Error", "Failed to load shipping options")13  } finally {14    setLoading(false)15  }16}, [cart])17
18const fetchPaymentProviders = useCallback(async () => {19  if (!cart) {return}20
21  try {22    setLoading(true)23    const { payment_providers } = await sdk.store.payment.listPaymentProviders({24      region_id: cart.region_id || "",25    })26    setPaymentProviders(payment_providers || [])27  } catch (err) {28    console.error("Failed to fetch payment providers:", err)29    Alert.alert("Error", "Failed to load payment providers")30  } finally {31    setLoading(false)32  }33}, [cart])34
35useEffect(() => {36  if (currentStep === "shipping") {37    fetchShippingOptions()38  } else if (currentStep === "payment") {39    fetchPaymentProviders()40  }41}, [currentStep, fetchShippingOptions, fetchPaymentProviders])42
43// TODO handle step transitions and order placement

You add a fetchShippingOptions function that retrieves shipping options, and a fetchPaymentProviders function that retrieves payment providers from the Medusa backend.

You also add a useEffect hook that calls these functions when the user navigates to the shipping or payment steps.

Next, you'll add functions that handle moving between steps and placing the order. Replace the // TODO handle step transitions and order placement comment with the following:

app/checkout.tsx
1const handleDeliveryNext = async () => {2  // Validate shipping address3  if (!email || !shippingAddress.firstName || !shippingAddress.lastName || 4      !shippingAddress.address || !shippingAddress.city || !shippingAddress.postalCode || 5      !shippingAddress.countryCode || !shippingAddress.phone) {6    Alert.alert("Error", "Please fill in all shipping address fields")7    return8  }9
10  // Validate billing address if different11  if (!useSameForBilling) {12    if (!billingAddress.firstName || !billingAddress.lastName || !billingAddress.address || 13        !billingAddress.city || !billingAddress.postalCode || !billingAddress.countryCode || 14        !billingAddress.phone) {15      Alert.alert("Error", "Please fill in all billing address fields")16      return17    }18  }19
20  if (!cart) {return}21
22  try {23    setLoading(true)24    const shippingAddressData = {25      first_name: shippingAddress.firstName,26      last_name: shippingAddress.lastName,27      address_1: shippingAddress.address,28      city: shippingAddress.city,29      postal_code: shippingAddress.postalCode,30      country_code: shippingAddress.countryCode,31      phone: shippingAddress.phone,32    }33
34    const billingAddressData = useSameForBilling ? shippingAddressData : {35      first_name: billingAddress.firstName,36      last_name: billingAddress.lastName,37      address_1: billingAddress.address,38      city: billingAddress.city,39      postal_code: billingAddress.postalCode,40      country_code: billingAddress.countryCode,41      phone: billingAddress.phone,42    }43
44    await sdk.store.cart.update(cart.id, {45      email,46      shipping_address: shippingAddressData,47      billing_address: billingAddressData,48    })49
50    await refreshCart()51    setCurrentStep("shipping")52  } catch (err) {53    console.error("Failed to update cart:", err)54    Alert.alert("Error", "Failed to save delivery information")55  } finally {56    setLoading(false)57  }58}59
60const handleShippingNext = async () => {61  if (!selectedShippingOption || !cart) {62    Alert.alert("Error", "Please select a shipping method")63    return64  }65
66  try {67    setLoading(true)68
69    await sdk.store.cart.addShippingMethod(cart.id, {70      option_id: selectedShippingOption,71    })72
73    await refreshCart()74    setCurrentStep("payment")75  } catch (err) {76    console.error("Failed to add shipping method:", err)77    Alert.alert("Error", "Failed to save shipping method")78  } finally {79    setLoading(false)80  }81}82
83const handlePlaceOrder = async () => {84  if (!selectedPaymentProvider || !cart) {85    Alert.alert("Error", "Please select a payment provider")86    return87  }88
89  try {90    setLoading(true)91
92    // Create payment session93    await sdk.store.payment.initiatePaymentSession(cart, {94      provider_id: selectedPaymentProvider,95    })96
97    // Complete cart (converts cart to order on backend)98    const result = await sdk.store.cart.complete(cart.id)99
100    if (result.type === "order") {101      // Navigate to order confirmation first102      // Cart will be cleared on the order confirmation page to prevent empty cart flash103      // TODO navigate to order confirmation screen with order details104    } else {105      Alert.alert("Error", result.error?.message || "Failed to complete order")106    }107  } catch (err: any) {108    console.error("Failed to complete order:", err)109    Alert.alert("Error", err?.message || "Failed to complete order")110  } finally {111    setLoading(false)112  }113}114
115// TODO render step components

The following functions are added:

  • handleDeliveryNext: Validates the delivery information, updates the cart with the email and addresses, and moves to the shipping step.
  • handleShippingNext: Validates the selected shipping option, adds it to the cart, and moves to the payment step.
  • handlePlaceOrder: Validates the selected payment provider, initiates the payment session, completes the cart, and handles the order confirmation. This function should also navigate to an order confirmation screen, which you'll implement later.

Finally, you'll render the appropriate step component based on the current step. Replace the // TODO render step components comment with the following:

app/checkout.tsx
1if (!cart) {2  return (3    <View style={[styles.centerContainer, { backgroundColor: colors.background }]}>4      <Text style={[styles.errorText, { color: colors.text }]}>5        No cart found. Please add items to your cart first.6      </Text>7    </View>8  )9}10
11// Active step uses inverted colors: white bg with dark text in dark mode, tint bg with white text in light mode12const activeStepBg = colorScheme === "dark" ? "#fff" : colors.tint13const activeStepText = colorScheme === "dark" ? "#000" : "#fff"14
15return (16  <View style={[styles.container, { backgroundColor: colors.background }]}>17    <View style={[styles.steps, { borderBottomColor: colors.border }]}>18      {(["delivery", "shipping", "payment"] as CheckoutStep[]).map((step, index) => (19        <View key={step} style={styles.stepIndicator}>20          <View21            style={[22              styles.stepCircle,23              {24                backgroundColor:25                  currentStep === step ? activeStepBg : colors.icon + "30",26              },27            ]}28          >29            <Text30              style={[31                styles.stepNumber,32                { color: currentStep === step ? activeStepText : colors.icon },33              ]}34            >35              {index + 1}36            </Text>37          </View>38          <Text39            style={[40              styles.stepLabel,41              {42                color: currentStep === step ? colors.text : colors.icon,43                fontWeight: currentStep === step ? "600" : "400",44              },45            ]}46          >47            {step.charAt(0).toUpperCase() + step.slice(1)}48          </Text>49        </View>50      ))}51    </View>52
53    <View style={styles.content}>54      {currentStep === "delivery" && (55        <DeliveryStep56          email={email}57          shippingAddress={shippingAddress}58          billingAddress={billingAddress}59          useSameForBilling={useSameForBilling}60          loading={loading}61          onEmailChange={setEmail}62          onShippingAddressChange={(field, value) => 63            setShippingAddress((prev) => ({ ...prev, [field]: value }))64          }65          onBillingAddressChange={(field, value) => 66            setBillingAddress((prev) => ({ ...prev, [field]: value }))67          }68          onUseSameForBillingChange={setUseSameForBilling}69          onNext={handleDeliveryNext}70        />71      )}72
73      {currentStep === "shipping" && (74        <ShippingStep75          shippingOptions={shippingOptions}76          selectedShippingOption={selectedShippingOption}77          currencyCode={cart.currency_code}78          loading={loading}79          onSelectOption={setSelectedShippingOption}80          onBack={() => setCurrentStep("delivery")}81          onNext={handleShippingNext}82        />83      )}84
85      {currentStep === "payment" && (86        <PaymentStep87          cart={cart}88          paymentProviders={paymentProviders}89          selectedPaymentProvider={selectedPaymentProvider}90          loading={loading}91          onSelectProvider={setSelectedPaymentProvider}92          onBack={() => setCurrentStep("shipping")}93          onPlaceOrder={handlePlaceOrder}94        />95      )}96    </View>97  </View>98)

If the cart isn't set, a message prompts the user to add items to their cart.

Otherwise, the step indicators are rendered at the top of the screen, and the current step's component is shown.

Add Checkout Screen to Navigation#

Next, you'll add the checkout screen to the app's navigation structure. You'll add it as a top-level stack screen.

In app/_layout.tsx, Replace the TODO: Add checkout and order confirmation screens comment with the following:

app/_layout.tsx
1<Stack.Screen2  name="checkout"3  options={{4    headerShown: true,5    title: "Checkout",6    presentation: "card",7    headerBackButtonDisplayMode: "minimal",8  }}9/>

Update Cart Screen to Navigate to Checkout#

Finally, you'll update the cart screen to navigate to the checkout screen when the user taps the "Proceed to Checkout" button.

In app/(drawer)/(tabs)/(cart)/index.tsx, find the Button at the end of the CartScreen component's return statement, and change the onPress prop to the following:

app/(drawer)/(tabs)/(cart)/index.tsx
1<Button2  title="Proceed to Checkout"3  onPress={() => router.push("/checkout")}4  loading={loading}5/>

If you get a type error regarding the /checkout route, the error will be resolved when you run the app.

Test the Checkout Flow#

To test the checkout flow, run your Medusa application and Expo app.

Then, in your Expo app, click on the "Proceed to Checkout" button in the cart screen. You should be taken to the checkout screen with the Delivery step as the current step.

In the Delivery step, fill in the email and address fields, then click "Continue" to proceed to the Shipping step.

Delivery step with address filled

Then, in the Shipping step, select a shipping method and click "Continue" to proceed to the Payment step.

Shipping step with shipping method selected

Finally, in the Payment step, select a payment provider. You currently can place an order, but there is no order confirmation screen yet. You'll add it in the next step.

Payment step with payment provider selected


Step 12: Create Order Confirmation Screen#

The last step in this tutorial is to create an order confirmation screen that customers are taken to after successfully placing an order.

You'll implement the order confirmation screen, then add it to the navigation structure.

Create Order Confirmation Screen Component#

To create the order confirmation screen, create the file app/order-confirmation/[id].tsx with the following content:

app/order-confirmation/[id].tsx
1import { Loading } from "@/components/loading"2import { Button } from "@/components/ui/button"3import { IconSymbol } from "@/components/ui/icon-symbol"4import { Colors } from "@/constants/theme"5import { useCart } from "@/context/cart-context"6import { useColorScheme } from "@/hooks/use-color-scheme"7import { formatPrice } from "@/lib/format-price"8import { getPaymentProviderInfo } from "@/lib/payment-providers"9import { sdk } from "@/lib/sdk"10import type { HttpTypes } from "@medusajs/types"11import { Image } from "expo-image"12import { useLocalSearchParams, useRouter } from "expo-router"13import React, { useCallback, useEffect, useRef, useState } from "react"14import { ScrollView, StyleSheet, Text, View } from "react-native"15
16export default function OrderConfirmationScreen() {17  const { id } = useLocalSearchParams<{ id: string }>()18  const router = useRouter()19  const colorScheme = useColorScheme()20  const colors = Colors[colorScheme ?? "light"]21  const { clearCart } = useCart()22
23  const [order, setOrder] = useState<HttpTypes.StoreOrder | null>(null)24  const [loading, setLoading] = useState(true)25  const [error, setError] = useState<string | null>(null)26  const hasCleared = useRef(false)27
28  // TODO fetch order details and clear cart29}30
31const styles = StyleSheet.create({32  container: {33    flex: 1,

The OrderConfirmationScreen component includes the following state variables:

  • order: The order details fetched from the backend.
  • loading: A boolean indicating whether the order details are being loaded.
  • error: An error message if fetching the order details fails.

Next, you'll fetch the order details using the order ID from the URL parameters, and clear the cart once the order is successfully placed. Replace the // TODO fetch order details and clear cart comment with the following:

app/order-confirmation/[id].tsx
1const fetchOrder = useCallback(async () => {2  try {3    setLoading(true)4    setError(null)5
6    const { order: fetchedOrder } = await sdk.store.order.retrieve(id, {7      fields: "*payment_collections.payments",8    })9    setOrder(fetchedOrder)10  } catch (err) {11    console.error("Failed to fetch order:", err)12    setError("Failed to load order details")13  } finally {14    setLoading(false)15  }16}, [id])17
18// Fetch order when id changes19useEffect(() => {20  if (id) {21    fetchOrder()22  }23}, [id, fetchOrder])24
25// Clear cart when order confirmation page loads (only once)26useEffect(() => {27  if (!hasCleared.current) {28    hasCleared.current = true29    clearCart()30  }31}, [clearCart])32
33// TODO render order details

The fetchOrder function retrieves order details from the Medusa backend using the order ID from the URL parameters.

Two useEffect hooks are added:

  1. One calls fetchOrder when the order ID changes.
  2. Another clears the cart when the order confirmation page loads, ensuring it runs only once.

Finally, you'll render the order details. Replace the // TODO render order details comment with the following:

app/order-confirmation/[id].tsx
1if (loading) {2  return <Loading message="Loading order details..." />3}4
5if (error || !order) {6  return (7    <View style={[styles.centerContainer, { backgroundColor: colors.background }]}>8      <Text style={[styles.errorText, { color: colors.text }]}>9        {error || "Order not found"}10      </Text>11      <Button12        title="Go to Home"13        onPress={() => {14          // Reset to home screen and clear the navigation stack15          router.dismissAll()16          router.replace("/(drawer)/(tabs)/(home)")17        }}18        style={styles.button}19      />20    </View>21  )22}23
24return (25  <ScrollView style={[styles.container, { backgroundColor: colors.background }]}>26    <View style={styles.content}>27      <View style={[styles.successIcon, { backgroundColor: colors.success }]}>28        <Text style={styles.checkmark}></Text>29      </View>30
31      <Text style={[styles.title, { color: colors.text }]}>Order Confirmed!</Text>32      <Text style={[styles.subtitle, { color: colors.icon }]}>33        We have received your order and will process it as soon as possible.34      </Text>35
36      <Button37        title="Continue Shopping"38        onPress={() => {39          // Reset to home screen and clear the navigation stack40          router.dismissAll()41          router.replace("/(drawer)/(tabs)/(home)")42        }}43        style={styles.continueButton}44      />45
46      <View style={[styles.card, { backgroundColor: colors.background, borderColor: colors.icon + "30" }]}>47        <Text style={[styles.cardTitle, { color: colors.text }]}>Order Details</Text>48        49        <View style={styles.infoRow}>50          <Text style={[styles.label, { color: colors.icon }]}>Order ID</Text>51          <Text style={[styles.value, { color: colors.text }]}>{order.display_id}</Text>52        </View>53
54        <View style={styles.infoRow}>55          <Text style={[styles.label, { color: colors.icon }]}>Email</Text>56          <Text style={[styles.value, { color: colors.text }]}>{order.email}</Text>57        </View>58      </View>59
60      <View style={[styles.card, { backgroundColor: colors.background, borderColor: colors.icon + "30" }]}>61        <Text style={[styles.cardTitle, { color: colors.text }]}>Order Items</Text>62        63        {order.items?.map((item, index) => (64          <View 65            key={item.id} 66            style={[67              styles.itemRow,68              index === order.items!.length - 1 && styles.lastItemRow,69              { borderBottomColor: colors.icon + "30" },70            ]}71          >72            <Image73              source={{ uri: item.thumbnail || "https://via.placeholder.com/60" }}74              style={[styles.itemImage, { backgroundColor: colors.imagePlaceholder }]}75              contentFit="cover"76            />77            <View style={styles.itemInfo}>78              <Text style={[styles.itemTitle, { color: colors.text }]}>79                {item.product_title || item.title}80              </Text>81              {item.variant_title && (82                <Text style={[styles.itemVariant, { color: colors.icon }]}>83                  {item.variant_title}84                </Text>85              )}86              <Text style={[styles.itemQuantity, { color: colors.icon }]}>87                Qty: {item.quantity}88              </Text>89            </View>90            <Text style={[styles.itemPrice, { color: colors.text }]}>91              {formatPrice(item.subtotal, order.currency_code)}92            </Text>93          </View>94        ))}95      </View>96
97      <View style={[styles.card, { backgroundColor: colors.background, borderColor: colors.icon + "30" }]}>98        <Text style={[styles.cardTitle, { color: colors.text }]}>Shipping</Text>99        100        {order.shipping_address && (101          <>102            <Text style={[styles.sectionTitle, { color: colors.text }]}>103              Shipping Address104            </Text>105            <Text style={[styles.addressText, { color: colors.text }]}>106              {order.shipping_address.first_name} {order.shipping_address.last_name}107            </Text>108            <Text style={[styles.addressText, { color: colors.text }]}>109              {order.shipping_address.address_1}110            </Text>111            <Text style={[styles.addressText, { color: colors.text }]}>112              {order.shipping_address.city}, {order.shipping_address.postal_code}113            </Text>114            <Text style={[styles.addressText, { color: colors.text }]}>115              {order.shipping_address.country_code?.toUpperCase()}116            </Text>117          </>118        )}119
120        {order.shipping_methods && order.shipping_methods.length > 0 && (121          <>122            <Text style={[styles.sectionTitle, { color: colors.text }]}>123              Shipping Method124            </Text>125            {order.shipping_methods.map((method) => (126              <Text key={method.id} style={[styles.addressText, { color: colors.text }]}>127                {method.name} - {formatPrice(method.amount || 0, order.currency_code)}128              </Text>129            ))}130          </>131        )}132      </View>133
134      <View style={[styles.card, { backgroundColor: colors.background, borderColor: colors.icon + "30" }]}>135        <Text style={[styles.cardTitle, { color: colors.text }]}>Payment</Text>136        137        {order.payment_collections && order.payment_collections.length > 0 && (138          <>139            <Text style={[styles.sectionTitle, { color: colors.text }]}>140              Payment Method141            </Text>142            {order.payment_collections[0].payments?.map((payment) => {143              const providerInfo = getPaymentProviderInfo(payment.provider_id)144              return (145                <View key={payment.id} style={styles.paymentMethodRow}>146                  <IconSymbol147                    size={20}148                    name={providerInfo.icon as any}149                    color={colors.text}150                  />151                  <Text style={[styles.addressText, { color: colors.text, marginLeft: 8 }]}>152                    {providerInfo.title}153                  </Text>154                </View>155              )156            })}157          </>158        )}159
160        {order.billing_address && (161          <>162            <Text style={[styles.sectionTitle, { color: colors.text }]}>163              Billing Address164            </Text>165            <Text style={[styles.addressText, { color: colors.text }]}>166              {order.billing_address.first_name} {order.billing_address.last_name}167            </Text>168            <Text style={[styles.addressText, { color: colors.text }]}>169              {order.billing_address.address_1}170            </Text>171            <Text style={[styles.addressText, { color: colors.text }]}>172              {order.billing_address.city}, {order.billing_address.postal_code}173            </Text>174            <Text style={[styles.addressText, { color: colors.text }]}>175              {order.billing_address.country_code?.toUpperCase()}176            </Text>177          </>178        )}179      </View>180
181      <View style={[styles.card, { backgroundColor: colors.background, borderColor: colors.icon + "30" }]}>182        <Text style={[styles.cardTitle, { color: colors.text }]}>Order Summary</Text>183        184        <View style={styles.summaryRow}>185          <Text style={[styles.summaryLabel, { color: colors.text }]}>Subtotal</Text>186          <Text style={[styles.summaryValue, { color: colors.text }]}>187            {formatPrice(order.item_subtotal || 0, order.currency_code)}188          </Text>189        </View>190
191        <View style={styles.summaryRow}>192          <Text style={[styles.summaryLabel, { color: colors.text }]}>Discount</Text>193          <Text style={[styles.summaryValue, { color: colors.text }]}>194            {(order.discount_total || 0) > 0 ? "-" : ""}{formatPrice(order.discount_total || 0, order.currency_code)}195          </Text>196        </View>197
198        <View style={styles.summaryRow}>199          <Text style={[styles.summaryLabel, { color: colors.text }]}>Shipping</Text>200          <Text style={[styles.summaryValue, { color: colors.text }]}>201            {formatPrice(order.shipping_total || 0, order.currency_code)}202          </Text>203        </View>204
205        <View style={styles.summaryRow}>206          <Text style={[styles.summaryLabel, { color: colors.text }]}>Tax</Text>207          <Text style={[styles.summaryValue, { color: colors.text }]}>208            {formatPrice(order.tax_total || 0, order.currency_code)}209          </Text>210        </View>211
212        <View style={[styles.summaryRow, styles.totalRow, { borderTopColor: colors.border }]}>213          <Text style={[styles.totalLabel, { color: colors.text }]}>Total</Text>214          <Text style={[styles.totalValue, { color: colors.tint }]}>215            {formatPrice(order.total, order.currency_code)}216          </Text>217        </View>218      </View>219    </View>220  </ScrollView>221)

Three states are handled in the render method:

  • Loading: Displays a loading indicator while the order details are being fetched.
  • Error: Displays an error message if fetching the order details fails.
  • Success: Displays the order confirmation details, including the order ID, items, shipping, and payment information.

Add Order Confirmation Screen to Navigation#

Next, you'll add the order confirmation screen to the app's navigation structure. You'll add it as a top-level stack screen.

In app/_layout.tsx, add the following after the checkout screen you added earlier:

app/_layout.tsx
1<Stack.Screen2  name="order-confirmation/[id]"3  options={{4    headerShown: true,5    title: "Order Confirmed",6    headerLeft: () => null,7    gestureEnabled: false,8    headerBackVisible: false,9  }}10/>

Finally, you'll update the checkout screen to navigate to the order confirmation screen after successfully placing an order.

In app/checkout.tsx, add the following import at the top of the file:

Code
import { useRouter } from "expo-router"

Then, inside the CheckoutScreen component, initialize the router:

app/checkout.tsx
1export default function CheckoutScreen() {2  const router = useRouter()3  // ...4}

Finally, find the handlePlaceOrder function, and replace the // TODO navigate to order confirmation screen with order details comment with the following:

app/checkout.tsx
router.replace(`/order-confirmation/${result.order.id}`)

If you get a type error regarding the /order-confirmation/[id] route, the error will be resolved when you run the app.

Test the Order Confirmation Screen#

To test the order confirmation screen, run your Medusa application and Expo app.

Then, in your Expo app, go through the checkout flow as before. After selecting a payment provider and placing the order, you should be taken to the order confirmation screen displaying the order details.

Order confirmation screen


Next Steps#

You now have an ecommerce app built with React Native and Medusa, complete with product listing, cart management, checkout flow, and order confirmation.

You can expand this app to:

  1. Add customer authentication and account management.
  2. Integrate payment providers like Stripe.
  3. Publish the app to app stores.

Learn More about Medusa#

If you're new to Medusa, check out the main documentation, where you'll get a more in-depth understanding of all the concepts you've used in this guide and more.

To learn more about the commerce features that Medusa provides, check out Medusa's Commerce Modules.

Troubleshooting#

If you encounter issues during your development, check out the troubleshooting guides.

Getting Help#

If you encounter issues not covered in the troubleshooting guides:

  1. Visit the Medusa GitHub repository to report issues or ask questions.
  2. Join the Medusa Discord community for real-time support from community members.
Was this page helpful?
Ask Anything
Ask any questions about Medusa. Get help with your development.
You can also use the Medusa MCP server in Cursor, VSCode, etc...
FAQ
What is Medusa?
How can I create a module?
How can I create a data model?
How do I create a workflow?
How can I extend a data model in the Product Module?
Recipes
How do I build a marketplace with Medusa?
How do I build digital products with Medusa?
How do I build subscription-based purchases with Medusa?
What other recipes are available in the Medusa documentation?
Chat is cleared on refresh
Line break