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:
- Set up a Medusa application.
- Create an app with React Native and Expo.
- Connect the Expo app to the Medusa backend.
- 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.

Step 1: Install a Medusa Application#
Start by installing the Medusa application on your machine with the following command:
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.
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:
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:
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 atheme.tsfile.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, anduse-theme-color.ts.
To update the app's theme, replace the content of the constants/theme.ts file with the following:
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:
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 tohttp://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.
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:
- Open the Medusa Admin dashboard at
http://localhost:9000/appand log in. - Go to Settings > Publishable API Keys.
- 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:
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:
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:
- Create a region context to manage the selected region state.
- Create a region selector component that allows users to choose their country and currency from a list.
- 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:
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:
regions: An array of available regions fetched from the Medusa backend.selectedRegion: The currently selected region.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.setSelectedRegion: A function to update the selected region and country code.loading: A boolean indicating whether the regions are being loaded.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:
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:
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:
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:
- Use the
useRegionhook to access all regions, the selected region and country, and the function to set the selected region. - Use the
useColorSchemehook to get the current color scheme and apply the appropriate colors. - 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:
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:
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:
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:
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:
- Wrap the app in the
RegionProviderto provide the region context to all components. - 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:
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:
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:
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:
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:
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:
Then, in the RootLayout's return statement, add the CartProvider as a child of the RegionProvider and a parent of the Stack component:
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:
- Necessary utilities and components for the home screen.
- 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:
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:
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:
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:
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:
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:
- Loading State: If the products are still loading, you display the
Loadingcomponent with a message. - Error State: If there was an error fetching the products, you display an error message.
- Success State: If the products are successfully fetched, you render a
FlatListto 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:
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:
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:
The MainScreen component redirects to the home screen tab when accessed.
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:
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:
Then, run the following command in your Expo project directory to start the Expo server:
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.

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.

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:
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:
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:
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:
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:
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_inventoryis false (inventory tracking is disabled), ORinventory_quantityis 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:
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.colorSchemeandcolors: 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.toastVisibleandtoastMessage: 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:
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:
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:
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:
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:
- Loading: Displays the
ProductSkeletonwhile fetching product details. - Error: Displays an error message if fetching fails or the product is not found.
- 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:
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.

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:
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:
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
CartItemcomponent, 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:
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:
Then, in the TabLayout component, add the following before the return statement:
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:
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.

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:
- Delivery: Enter shipping and billing addresses.
- Shipping: Choose a shipping method.
- 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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.

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

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.

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:
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:
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:
- One calls
fetchOrderwhen the order ID changes. - 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:
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:
Navigate to Order Confirmation After Placing Order#
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:
Then, inside the CheckoutScreen component, initialize the router:
Finally, find the handlePlaceOrder function, and replace the // TODO navigate to order confirmation screen with order details comment with the following:
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.

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:
- Add customer authentication and account management.
- Integrate payment providers like Stripe.
- 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:
- Visit the Medusa GitHub repository to report issues or ask questions.
- Join the Medusa Discord community for real-time support from community members.