Build a Product Browser with React Native

Build a multi-screen grocery app with React Native, Expo Router, and FlatList.

Introduction

30 Second Summary

Every grocery app on your phone follows the same pattern. You scroll through products, tap a card to see details, then switch tabs to browse by category.

In this project, you will build that exact experience as a React Native app using Expo. You'll create a multi-screen product browser that runs on the iOS Simulator.

What You'll Build

You'll scroll through a grocery catalog on a simulated iPhone, tapping product cards to see details slide in from the right, just like a real App Store app.

By the end of this project, you'll have:

  • A scrollable grocery catalog where you flick through product cards showing images, names, and prices with smooth, native bounce.
  • A bottom tab bar with icons that lets you switch between browsing all products and browsing by category with a single tap.
  • A detail screen that slides in from the right when you tap any product, showing the full description, price, and an "Add to Cart" button.
  • Secret Mission: a real-time search bar that filters products instantly as you type.

Are there any prerequisites?

This project assumes you have React experience and are comfortable with components, props, state, and hooks.

You'll need Node.js, Xcode with the iOS Simulator configured, and a code editor like VS Code installed on macOS.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for your work.

Set Up Your Expo Project

Before you can build anything, you need a running app on the iOS Simulator. This step scaffolds the project, boots the dev server, and gets the simulator showing your app.

Expo is the officially recommended framework for new React Native projects. It handles the native build toolchain, provides file-based routing, and ships with TypeScript out of the box.

In this step, get ready to:
  • Scaffold a new Expo SDK 57 project.
  • Launch the app on the iOS Simulator.
  • Clear the starter code for a clean slate.
Scaffold the project and install dependencies
  • Open your terminal. Press Cmd+Space to open Spotlight, type Terminal, and press Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your new Expo project by running this command:
npx create-expo-app@latest ProductBrowser --template default@sdk-57

What does this command do?

This creates a new folder called ProductBrowser on your Desktop, downloads the default Expo template, and installs all the project dependencies.

The --template default@sdk-57 flag pins the project to Expo SDK 57. During the SDK 57 transition period, running create-expo-app@latest without this flag creates an SDK 54 project instead.

This command takes a couple of minutes to download the template and install dependencies. Once it finishes, you should see a confirmation message in your terminal.

  • Move into the project folder by running this command:
cd ProductBrowser
  • Confirm the project was created by running:
ls

You should see files like package.json, tsconfig.json, and a src/ directory listed in the output.

Command not found or network error?

  • Make sure Node.js is installed by running node --version. Expo SDK 57 requires Node.js 22.13.x or later.
  • If you see a network timeout, check your internet connection and try the command again.
  • Help me troubleshoot the create-expo-app command
Launch the app on the iOS Simulator

The project is scaffolded, but you have not seen it run yet. The Expo dev server compiles your code and sends it to the simulator over the local network.

  • Start the Expo dev server by running this command:
npx expo start

What does this command do?

This starts the Expo development server and shows a menu of options in your terminal. The dev server stays running and watches for file changes. It takes over this terminal session, so you will interact with it using the keyboard shortcuts shown in the menu.

  • Press i in the terminal to open the iOS Simulator.

First launch takes a moment

The first time you press i, Expo CLI boots the iOS Simulator and automatically installs Expo Go on it. This can take a minute or two. Subsequent launches are much faster.

You should see the default Expo app with a tab bar at the bottom showing two tabs: Home and Explore.

Simulator not opening?

  • Make sure Xcode is installed from the Mac App Store and you have an iOS Simulator image downloaded. Open Xcode, go to Settings, then Locations, and confirm a version is selected under Command Line Tools.
  • If the simulator opens but the app does not load, check that you have an iOS simulator runtime installed. In Xcode, go to Settings, then Components or Platform Support, and download an iOS runtime.
  • Help me get the iOS Simulator working with Expo
Clear the starter code

The default template ships with example screens, components, and styling you do not need. You need a blank slate so you can build the product browser from scratch.

  • Stop the dev server by pressing Ctrl+C in your terminal.

Heads up: this command is interactive

The reset script will ask if you want to keep the existing starter files as a reference instead of deleting them. When prompted, type Y and press Enter. This moves the starter code into an app-example/ directory so you can peek at it later if needed.

  • Reset the project to a blank slate by running this command:
npm run reset-project

What does this command do?

The reset script moves all the starter code (screens, components, assets) into an app-example/ directory. It then creates a fresh src/app/ directory with a minimal index.tsx file. This is your blank canvas.

Before you restart the server, what do you think the app will look like now that the starter code has been cleared?

  • Restart the dev server by running this command:
npx expo start
  • Press i again to reopen the iOS Simulator.

You should see a minimal screen with placeholder text instead of the previous tabbed interface. The tab bar, example screens, and all the starter styling are gone.

Still seeing the old app?

  • Make sure you stopped the dev server with Ctrl+C before running the reset script. If the server was still running, stop it now and restart with npx expo start.
  • Confirm the src/app/index.tsx file exists. Open a new terminal tab, navigate to your ProductBrowser folder, and run ls src/app/. You should see index.tsx listed.
  • My app still shows the old template after resetting

Your blank canvas is ready. Next up, you will fill that empty screen with a scrollable list of product cards.

Build the Product List Screen

Your Expo project is scaffolded and the iOS Simulator is showing a blank screen. Time to fill it with the core of the app.

The product list is the heart of this product browser. Building it teaches you two key mental model shifts from web development: React Native uses FlatList instead of mapping over divs for rendering lists, and StyleSheet replaces CSS for all styling.

In this step, get ready to:
  • Create mock product data with a TypeScript interface.
  • Build a reusable ProductCard component using React Native's core components.
  • Render a scrollable product list with FlatList on the Home screen.
Create the product data and card component

Every product browser needs products to browse. You will create a data file that defines the shape of a product and stores a catalog of 12 grocery items with names, prices, images, and categories.

  • Create a new file at src/data/products.ts in your editor. Your editor will create the src/data/ folder automatically.
  • Add the Product interface and the first two products:
// Define the shape of each product
export interface Product {
  id: string;
  name: string;
  price: number;
  image: string;
  category: string;
  description: string;
}

export const products: Product[] = [
  {
    id: '1',
    name: 'Whole Milk',
    price: 1.50,
    image: 'https://picsum.photos/seed/milk/400/300',
    category: 'Dairy',
    description: 'Fresh whole milk, 2 litres. Perfect for cereal, tea, and cooking. Sourced from local farms.',
  },
  {
    id: '2',
    name: 'Sourdough Bread',
    price: 2.80,
    image: 'https://picsum.photos/seed/bread/400/300',
    category: 'Bakery',
    description: 'Artisan sourdough loaf baked fresh daily. Crispy crust with a soft, tangy interior.',
  },

What does this code do?

The Product interface defines the TypeScript shape every product object must follow: an id, name, price, image URL, category, and description.

Each product's image uses a picsum.photos placeholder URL. These are free, randomly generated images that work without any API key.

  • Add the next three products below the last entry in the array:
  {
    id: '3',
    name: 'Free Range Eggs',
    price: 3.20,
    image: 'https://picsum.photos/seed/eggs/400/300',
    category: 'Dairy',
    description: 'Pack of 12 free range eggs. Great for breakfast, baking, and everything in between.',
  },
  {
    id: '4',
    name: 'Chicken Breast',
    price: 5.50,
    image: 'https://picsum.photos/seed/chicken/400/300',
    category: 'Meat',
    description: 'Lean chicken breast fillets, 500g. Versatile protein for grilling, roasting, or stir-frying.',
  },
  {
    id: '5',
    name: 'Bananas',
    price: 0.75,
    image: 'https://picsum.photos/seed/bananas/400/300',
    category: 'Fruit',
    description: 'Bunch of 5 ripe bananas. A quick, healthy snack packed with potassium.',
  },
  • Add products six through eight below:
  {
    id: '6',
    name: 'Cheddar Cheese',
    price: 3.00,
    image: 'https://picsum.photos/seed/cheese/400/300',
    category: 'Dairy',
    description: 'Mature cheddar cheese block, 400g. Sharp flavour, perfect for sandwiches and cooking.',
  },
  {
    id: '7',
    name: 'Croissants',
    price: 2.00,
    image: 'https://picsum.photos/seed/croissant/400/300',
    category: 'Bakery',
    description: 'Pack of 4 butter croissants. Flaky, golden, and delicious with jam or on their own.',
  },
  {
    id: '8',
    name: 'Salmon Fillet',
    price: 6.50,
    image: 'https://picsum.photos/seed/salmon/400/300',
    category: 'Meat',
    description: 'Fresh Atlantic salmon fillet, 300g. Rich in omega-3, ideal for pan-frying or baking.',
  },
  • Add products nine through eleven below:
  {
    id: '9',
    name: 'Strawberries',
    price: 2.50,
    image: 'https://picsum.photos/seed/strawberries/400/300',
    category: 'Fruit',
    description: 'Punnet of fresh strawberries, 400g. Sweet and juicy, perfect for desserts or snacking.',
  },
  {
    id: '10',
    name: 'Greek Yoghurt',
    price: 1.80,
    image: 'https://picsum.photos/seed/yoghurt/400/300',
    category: 'Dairy',
    description: 'Thick and creamy Greek yoghurt, 500g. High in protein, great with fruit or granola.',
  },
  {
    id: '11',
    name: 'Avocados',
    price: 1.50,
    image: 'https://picsum.photos/seed/avocado/400/300',
    category: 'Fruit',
    description: 'Pack of 2 ripe avocados. Creamy texture, ideal for toast, salads, and guacamole.',
  },
  • Add the final product, close the array, and add the categories export below:
  {
    id: '12',
    name: 'Baguette',
    price: 1.20,
    image: 'https://picsum.photos/seed/baguette/400/300',
    category: 'Bakery',
    description: 'Traditional French baguette, freshly baked. Crunchy outside, soft and airy inside.',
  },
];

// Extract unique category names from the products array
export const categories = [...new Set(products.map((p) => p.category))];
  • Save products.ts.
  • In the Explorer sidebar, you should see products.ts listed under src/data/.

What is the categories export?

The last line uses a Set to extract unique category names from the products array. Instead of manually listing categories, they are derived from the data. You will use this array to build the Categories tab in a later step.

✔️ Awesome, I've got everything!

Great. Make sure you have saved products.ts before moving on.

ⓧ I'd like to double check the full code

The full file is long, so scroll up and confirm these sections are present in order:

  • The Product interface with six properties (id, name, price, image, category, description).
  • The products array containing 12 product objects, starting with Whole Milk (id '1') and ending with Baguette (id '12').
  • The categories export using a Set to extract unique category names.

With the data ready, you need a component to display each product. In React Native, there are no HTML elements like <div> or <p>. Instead, you use View for layout containers, Text for any text, and Image for images.

  • Create a new file at src/components/product-card.tsx and add the component:
import { View, Text, Image, StyleSheet } from 'react-native';
import { Link } from 'expo-router';

import { Product } from '@/data/products';

interface ProductCardProps {
  product: Product;
}

export function ProductCard({ product }: ProductCardProps) {
  return (
    <Link href={`/product/${product.id}`} asChild>
      <View style={styles.card}>
        <Image source={{ uri: product.image }} style={styles.image} />
        <View style={styles.info}>
          <Text style={styles.name}>{product.name}</Text>
          <Text style={styles.category}>{product.category}</Text>
          <Text style={styles.price}>{`\u00a3${product.price.toFixed(2)}`}</Text>
        </View>
      </View>
    </Link>
  );
}

const styles = StyleSheet.create({
  card: { // Horizontal card layout
    flexDirection: 'row',
    backgroundColor: '#fff',
    overflow: 'hidden',
  },
  • Add the remaining styles inside the StyleSheet, below the card style:
  image: {
    width: 100,
    height: 100,
  },
  info: {
    flex: 1,
    padding: 12,
    justifyContent: 'center',
  },
  name: { // Product title text
    fontSize: 16,
    fontWeight: '600',
    color: '#1a1a1a',
  },
  category: {
    fontSize: 13,
    color: '#666',
    marginTop: 2,
  },
  price: {
    fontSize: 16,
    fontWeight: '700',
    color: '#00539f',
    marginTop: 6,
  },
});

What does this code do?

The ProductCard component takes a product prop and renders it as a horizontal card. View acts as a <div>, Text acts as a <p>, and Image acts as an <img>.

The Link component from Expo Router wraps the card so that tapping it navigates to /product/${product.id}. The asChild prop passes the navigation behaviour down to the child View instead of rendering an extra wrapper.

All styles are defined in a StyleSheet.create() call at the bottom. StyleSheet is React Native's replacement for CSS. Properties use camelCase (backgroundColor instead of background-color) and values are numbers, not strings with units.

What is @/ in the import path?

The @/ prefix is a path alias configured by the default template in tsconfig.json. It maps to ./src/*, so @/data/products resolves to src/data/products.ts. This saves you from writing long relative paths like ../../data/products.

  • Save product-card.tsx.
  • In the Explorer sidebar, you should see product-card.tsx listed under src/components/.

Seeing a red error in the simulator?

The simulator might show an error because index.tsx hasn't been updated to use the new component yet. That is expected. You will fix this in the next substep.

If you see a different error about the import path, check that the file is saved at exactly src/data/products.ts (not data/products.ts at the project root).

help me fix a red error in my Expo app after creating product-card.tsx and products.ts

Build the Home screen with FlatList

On the web, you would render a list by calling .map() on an array and returning a <div> for each item. React Native takes a different approach. FlatList only renders the items currently visible on screen, which keeps scrolling smooth even with hundreds of items.

  • Open src/app/index.tsx and replace its entire contents with this code:
import { View, FlatList, Text, StyleSheet } from 'react-native';

import { products } from '@/data/products';
import { ProductCard } from '@/components/product-card';

export default function ProductsScreen() {
  return (
    <View style={styles.container}>
      {/* FlatList only renders items visible on screen */}
      <FlatList
        data={products}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => <ProductCard product={item} />}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
});

What does this code do?

FlatList takes three key props. data is the array of products. keyExtractor tells React Native how to uniquely identify each item (using the product's id). renderItem defines what component to render for each item in the list.

The container style uses flex: 1 to fill the entire screen. Without it, the list would collapse to zero height.

  • Save index.tsx.

Before you check the simulator, what do you think the Home screen looks like now? Do you expect to see product images, or just text?

The simulator auto-refreshes. You should see a scrollable list of product cards, each showing a placeholder image on the left and the product name, category, and price on the right.

Why FlatList instead of .map()?

On the web, rendering 1,000 items with .map() creates 1,000 DOM nodes at once. The browser handles it because it can scroll an arbitrarily tall page. On mobile, that would freeze the app.

FlatList solves this by only mounting the items visible in the viewport. As you scroll, it recycles off-screen items and renders new ones on demand. This is called virtualisation, and it is why native lists feel so responsive.

Not seeing the product list?

Make sure all three files are saved: products.ts, product-card.tsx, and index.tsx.

Check that products.ts is inside src/data/ and product-card.tsx is inside src/components/. If the folders are at the project root instead of inside src/, the @/ imports will fail.

If you see a blank screen, check that index.tsx exports a default function. Expo Router requires default exports for route files.

help me debug why my product list is not showing in the iOS Simulator

Style the product cards for a native look

The cards are visible, but they look flat. Native apps use subtle shadows to create depth. iOS and Android handle shadows differently, so you need both approaches.

  • In product-card.tsx, find the card style object:
  card: {
    flexDirection: 'row',
    backgroundColor: '#fff',
    overflow: 'hidden',
  },
  • Replace it with this version that adds border radius, shadows, and Android elevation:
  card: {
    flexDirection: 'row',
    backgroundColor: '#fff',
    borderRadius: 12,
    overflow: 'hidden',
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 8,
    elevation: 3,
  },

What did you add?

The borderRadius rounds the card corners. The four shadow* properties create a soft drop shadow on iOS. Android ignores those properties entirely and uses elevation instead to produce a similar effect. Including both ensures the card looks right on either platform.

  • In index.tsx, find the FlatList component:
      <FlatList
        data={products}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => <ProductCard product={item} />}
      />
  • Add contentContainerStyle and ItemSeparatorComponent props so the closing tag looks like this:
      <FlatList
        data={products}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => <ProductCard product={item} />}
        contentContainerStyle={styles.list}
        ItemSeparatorComponent={() => <View style={styles.separator} />}
      />
  • In the same file, add these two styles inside the StyleSheet.create() call, below the container style:
  list: {
    padding: 16,
  },
  separator: {
    height: 12,
  },
  • Save both product-card.tsx and index.tsx.

What difference do you think the shadows and spacing make? Try scrolling the list now.

The cards now have rounded corners, a subtle shadow giving them depth, and 12 pixels of breathing room between each card. Scrolling feels smooth and bouncy with the native iOS scroll physics.

Don't tap the cards yet

The Link in ProductCard points to /product/[id], which is a route that does not exist yet. Tapping a card will show an unmatched route screen. You will build the product detail screen in Step 4.

Cards still look flat?

Make sure you replaced the entire card style object, not just added lines inside it. The borderRadius: 12 line must appear before overflow: 'hidden'.

If you see no spacing between cards, check that you added both the list and separator styles to the StyleSheet in index.tsx, and that the ItemSeparatorComponent prop is on the FlatList.

help me fix the styling on my product cards in React Native

✔️ Awesome, I've got everything!

Great. Make sure you have saved both product-card.tsx and index.tsx.

ⓧ I'd like to double check the full code

Both files are too long to show in full. Scroll up and confirm each section is present:

For product-card.tsx:

  • Imports from react-native, expo-router, and @/data/products.
  • The ProductCard function wrapping a Link with asChild, containing Image and three Text components (name, category, price).
  • The card style with borderRadius: 12, shadowColor, shadowOffset, shadowOpacity, shadowRadius, and elevation: 3.
  • Styles for image, info, name, category, and price.

For index.tsx:

  • Imports from react-native, @/data/products, and @/components/product-card.
  • A FlatList with data, keyExtractor, renderItem, contentContainerStyle, and ItemSeparatorComponent props.
  • Styles for container, list, and separator.

Your product list is live and scrollable. Next up, you will add tab navigation so users can browse products by category.

Add Tab Navigation

Your product list looks great, but right now every product lives on a single screen. A real product browser lets you switch between different views, like browsing all products or filtering by category.

Tab navigation is the most common pattern in mobile apps. Expo Router makes it file-based, similar to how Next.js routing works. You create a special (tabs) directory, drop screen files inside it, and the tab bar appears automatically.

In this step, get ready to:
  • Move the product list into a tab layout with Expo Router's file-based routing.
  • Build a Categories screen listing product categories as tappable rows.
  • Add tab bar icons and active state styling with Ionicons.
Set up the tab layout with Expo Router

Expo Router uses a (tabs) directory as a route group. Any screen file inside this directory becomes a tab. The parentheses tell the router this is a layout group, not a URL segment.

  • Create the (tabs) directory and move your product list screen into it by running these commands:
mkdir "src/app/(tabs)"
mv src/app/index.tsx "src/app/(tabs)/index.tsx"
  • Confirm the file moved by running:
ls "src/app/(tabs)/"

You should see index.tsx in the output.

Why do the parentheses need quotes?

Parentheses are special characters in bash. Without quotes, the terminal tries to interpret them as shell syntax and throws a syntax error. Wrapping the path in double quotes tells bash to treat the parentheses as literal characters.

  • Create a new file called src/app/_layout.tsx and add this code:
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      {/* Render the tab navigator as the main screen */}
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
    </Stack>
  );
}

What does this layout do?

The root layout wraps your entire app in a Stack navigator. The (tabs) screen tells Expo Router to load the tab layout from the src/app/(tabs)/ directory.

Setting headerShown: false hides the stack header so the tab navigator's own headers show instead.

  • Create a new file called src/app/(tabs)/_layout.tsx and add this code:
import { Tabs } from 'expo-router';

export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen
        name="index"
        options={{
          title: 'Products',
        }}
      />
      <Tabs.Screen
        name="categories"
        options={{
          title: 'Categories',
        }}
      />
    </Tabs>
  );
}

What does this layout do?

The Tabs component from expo-router creates a bottom tab bar. Each Tabs.Screen maps a file name to a tab. The name prop matches the filename without the extension.

The index screen maps to index.tsx (your product list). The categories screen maps to categories.tsx, which you will create in the next substep.

  • Save both _layout.tsx files.
  • Check the iOS Simulator. You should see your product list on the Products tab, with a tab bar at the bottom of the screen showing both Products and Categories labels.

Don't see the tab bar?

  • Make sure you moved index.tsx into src/app/(tabs)/, not just copied it. Run ls src/app/ to confirm there is no stray index.tsx left at the top level.
  • Check that src/app/_layout.tsx exists and has the Stack with name="(tabs)". Without the root layout, Expo Router does not know about your tab group.
  • If the dev server shows an error, stop it with Ctrl+C and restart with npx expo start, then press i to reopen the simulator.

Still stuck? Why isn't my tab bar showing up in the iOS Simulator?

Build the Categories screen

The Categories tab currently shows a blank screen because the categories.tsx file does not exist yet. Let's create it with a FlatList of category names extracted from your product data.

  • Create a new file called src/app/(tabs)/categories.tsx and add the component code:
import { View, Text, FlatList, StyleSheet } from 'react-native';
import { Link } from 'expo-router';

import { categories } from '@/data/products';

export default function CategoriesScreen() {
  return (
    <View style={styles.container}>
      <FlatList
        data={categories}
        keyExtractor={(item) => item}
        renderItem={({ item }) => (
          <Link href={`/category/${item}`} asChild>
            <View style={styles.row}>
              <Text style={styles.categoryName}>{item}</Text>
              <Text style={styles.arrow}>›</Text>
            </View>
          </Link>
        )}
        ItemSeparatorComponent={() => <View style={styles.separator} />}
      />
    </View>
  );
}

What does this component do?

The categories array is imported from products.ts. It is a list of unique category strings extracted from your product data using a Set.

Each category renders as a full-width row with a right-arrow indicator. The Link component wraps each row so tapping it will navigate to a filtered view later. For now, the /category/ route does not exist yet, so tapping a category will not navigate anywhere.

  • Still in categories.tsx, add the styles below the component:
const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  row: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    backgroundColor: '#fff',
    padding: 16,
    borderRadius: 8,
    marginHorizontal: 16,
  },
  categoryName: {
    fontSize: 16,
    fontWeight: '500',
    color: '#1a1a1a',
  },
  arrow: {
    fontSize: 22,
    color: '#999',
  },
  separator: {
    height: 8,
  },
});

How are these styles different from product cards?

Category rows use full-width styling with marginHorizontal instead of cards. The flexDirection: 'row' with justifyContent: 'space-between' pushes the category name to the left and the arrow indicator to the right. This makes them visually distinct from the product cards.

  • Save categories.tsx.
  • In the iOS Simulator, tap the Categories tab at the bottom. You should see a list of category names (Dairy, Bakery, Meat, Fruit) displayed as tappable rows with right-arrow indicators.

Categories tab still blank?

  • Check that categories.tsx is inside src/app/(tabs)/. The filename must match the name="categories" in your tab layout exactly.
  • Verify categories.tsx has a default export. Without export default, Expo Router cannot find the screen component.

Need help? My Categories tab is blank even though I created the file.

✔️ Awesome, I've got everything!

Great. Double check you have saved categories.tsx and both tabs are showing content.

ⓧ I'd like to double check the full code

The categories.tsx file has two sections you built above. Scroll up and confirm you have both:

  • The imports and CategoriesScreen component function (the first code block).
  • The styles object with container, row, categoryName, arrow, and separator (the second code block).
Add tab bar icons and styling

The tab bar works but the labels look plain without icons. Expo ships with Ionicons (and other icon sets) through the @expo/vector-icons package, which is already included in your project.

  • Open src/app/(tabs)/_layout.tsx and replace its entire contents with this updated version:
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#00539f',
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Products',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="grid-outline" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="categories"
        options={{
          title: 'Categories',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="list-outline" size={size} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}

What changed here?

  • The Ionicons import from @expo/vector-icons gives you access to hundreds of icons bundled with Expo.
  • Each tab screen now has a tabBarIcon function that receives the current color and size from the tab bar and renders the matching Ionicons icon.
  • The tabBarActiveTintColor: '#00539f' in screenOptions makes the active tab icon and label turn blue, so the learner always knows which tab they are on.
  • Save src/app/(tabs)/_layout.tsx.

Before you check the simulator, which tab do you think will be highlighted in blue when the app reloads?

Check the iOS Simulator. The Products tab is the default, so it should be highlighted in blue with the grid icon. Tap Categories and the list icon turns blue while Products goes grey.

Icons not showing?

  • Make sure the Ionicons import line is present. It comes from @expo/vector-icons, not react-native-vector-icons.
  • Check that tabBarIcon is inside the options object, not the screenOptions object.

Still stuck? Why aren't my tab bar icons appearing after adding Ionicons?

✔️ Awesome, I've got everything!

Great. Make sure all your files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the root layout file:

import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
    </Stack>
  );
}

Here is the tab layout file:

import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#00539f',
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Products',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="grid-outline" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="categories"
        options={{
          title: 'Categories',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="list-outline" size={size} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}

Your src/app/(tabs)/index.tsx is unchanged from the previous step. Scroll up to the Categories substep to double check categories.tsx.

Your app now has proper tab navigation with styled icons. Next up, you will make those product cards do something when tapped by adding a detail screen with stack navigation.

Add Product Detail Screen with Stack Navigation

Your product list looks great and you can switch between two tabs. However, tapping a product card does nothing visible yet because the detail route doesn't exist.

On mobile, tapping an item in a list pushes a detail screen onto the navigation stack. The new screen slides in from the right on iOS, and a back button appears automatically. In this step, you'll build a product detail screen and register it as a dynamic route so tapping any card opens it.

In this step, get ready to:
  • Create a dynamic route for the product detail screen.
  • Build the detail layout with a product image, description, price, and an "Add to Cart" button.
  • Register the route in the root Stack so product cards navigate to it.
Create the dynamic product detail route

Expo Router uses square bracket notation in filenames to create dynamic routes. A file named [id].tsx matches any value in that URL segment, similar to how Next.js dynamic routes work.

  • In your code editor, create a new file at src/app/product/[id].tsx.
  • Paste the following imports and route parameter logic into the new file:

Creating nested folders

Your editor creates the product/ folder automatically when you create the file at that path.

import { View, Text, Image, ScrollView, Pressable, StyleSheet } from 'react-native';
import { useLocalSearchParams } from 'expo-router';

import { products } from '@/data/products';

export default function ProductDetailScreen() {
  // Read the product id from the URL
  const { id } = useLocalSearchParams<{ id: string }>();
  // Find the matching product in the data array
  const product = products.find((p) => p.id === id);

  // Guard clause if the product doesn't exist
  if (!product) {
    return (
      <View style={styles.container}>
        <Text>Product not found</Text>
      </View>
    );
  }

What does this code do?

useLocalSearchParams reads the id value from the URL. When you navigate to /product/3, the hook returns { id: '3' }.

The guard clause handles the case where no product matches the id. This keeps TypeScript happy and prevents crashes if someone navigates to an invalid URL.

Build the detail screen layout

The detail screen needs a large product image, text details, and an interactive "Add to Cart" button. You'll wrap everything in a ScrollView so long descriptions scroll naturally.

  • Still in [id].tsx, add the main return statement below the not-found guard:
  return (
    <ScrollView style={styles.container}>
      <Image source={{ uri: product.image }} style={styles.image} />
      <View style={styles.content}>
        <Text style={styles.category}>{product.category}</Text>
        <Text style={styles.name}>{product.name}</Text>
        <Text style={styles.price}>{`£${product.price.toFixed(2)}`}</Text>
        <Text style={styles.description}>{product.description}</Text>
        <Pressable
          style={({ pressed }) => [
            styles.button,
            pressed && styles.buttonPressed,
          ]}
        >
          <Text style={styles.buttonText}>Add to Cart</Text>
        </Pressable>
      </View>
    </ScrollView>
  );
}

What does this layout do?

ScrollView wraps the content so the screen scrolls when the description is longer than the viewport. This is a common pattern for detail screens.

Pressable is React Native's touch handler. The style prop receives a function that checks whether the button is currently pressed. When the user holds down the button, buttonPressed reduces the opacity to give tactile feedback.

  • Below the component function (still in the same file), add the first group of styles:
const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
  image: {
    width: '100%',
    height: 250,
  },
  content: {
    padding: 20,
  },
  category: {
    fontSize: 14,
    color: '#666',
    textTransform: 'uppercase',
    letterSpacing: 1,
  },
  name: {
    fontSize: 24,
    fontWeight: '700',
    color: '#1a1a1a',
    marginTop: 4,
  },
  price: {
    fontSize: 22,
    fontWeight: '700',
    color: '#00539f',
    marginTop: 8,
  },
});

What do these styles do?

  • image stretches the product photo to full width at 250px tall.
  • category uses textTransform: 'uppercase' to make the category label stand out as a subtle heading.
  • price uses the same blue accent colour #00539f as the product cards for visual consistency.
  • Save [id].tsx.

You'll add the remaining styles for the description and button after you test the screen.

Register the route and test the detail screen

Your ProductCard component already wraps each card in a Link pointing to /product/${product.id} from Step 2. The route file now exists, but the root Stack doesn't know about it yet.

  • Switch back to src/app/_layout.tsx.
  • Find this line inside the <Stack> component:
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
  • Add a new Stack.Screen directly below it, so the Stack now looks like this:
<Stack>
  <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
  <Stack.Screen name="product/[id]" options={{ title: 'Product Details' }} />
</Stack>

Why does this screen sit outside the tabs?

The product detail route lives at src/app/product/[id].tsx, outside the (tabs)/ directory. When a Link inside the tabs points to it, the screen pushes onto the root Stack above the tab bar. This is the standard iOS pattern for detail screens. The tab bar disappears and a back button appears in the header.

  • Save _layout.tsx.

Before you tap a card, what do you think will happen? Will the detail screen appear instantly, or will it animate in from a specific direction?

  • In the iOS Simulator, tap any product card on the Products tab.

The detail screen slides in from the right. You should see the product image at the top, the category label in uppercase, the product name, and the price in blue. The header shows "Product Details" with a back arrow on the left. The description text and Add to Cart button are visible but not yet fully styled.

  • Tap the back arrow in the header to return to the product list.

Tapping a card does nothing?

  • Check that your file is saved at exactly src/app/product/[id].tsx with square brackets around id. The brackets are part of the actual filename.
  • Verify that _layout.tsx has name="product/[id]" with the square brackets included in the string.
  • Make sure the dev server is still running. If it stopped, restart with npx expo start and press i.

Help me debug why tapping a product card isn't opening the detail screen

Now finish the styling. The description and button need their own style entries.

  • Switch back to src/app/product/[id].tsx.
  • Find the closing }); at the bottom of your StyleSheet.create call.
  • Add these style entries directly above that closing line:
  description: {
    fontSize: 16,
    lineHeight: 24,
    color: '#333',
    marginTop: 16,
  },
  button: {
    backgroundColor: '#00539f',
    borderRadius: 12,
    padding: 16,
    alignItems: 'center',
    marginTop: 24,
  },
  buttonPressed: {
    opacity: 0.8,
  },
  buttonText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: '600',
  },

What do these styles add?

  • description sets a comfortable lineHeight of 24 so multi-line text is easy to read.
  • button gives the Add to Cart button a blue background, rounded corners, and centered text.
  • buttonPressed reduces opacity to 0.8 when the user holds down the button, giving it a native press-down feel.
  • Save [id].tsx.
  • In the iOS Simulator, tap any product card again.

The detail screen now shows the full description with comfortable line spacing and a blue "Add to Cart" button with rounded corners. Press and hold the button to see the opacity dip.

Button or description still unstyled?

  • Make sure you added the new styles inside the StyleSheet.create({ }) call, not outside it. The entries should sit between the price style and the closing });.
  • Check for a missing comma after the price style block. Each style entry must end with a comma.

Help me fix my StyleSheet styles in the product detail screen

✔️ Awesome, I've got everything!

Great. Double check you've saved both [id].tsx and _layout.tsx.

ⓧ I'd like to double check the full code

Scroll up and confirm your [id].tsx file contains all four code sections in order:

  • The imports and ProductDetailScreen function with the not-found guard.
  • The return statement with ScrollView, product details, and the Pressable button.
  • The StyleSheet with container, image, content, category, name, and price styles.
  • The description, button, buttonPressed, and buttonText styles inside the same StyleSheet.create call.

Your _layout.tsx should look like this:

import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="product/[id]" options={{ title: 'Product Details' }} />
    </Stack>
  );
}

Your product detail screen is working with smooth stack navigation. Next up, you'll connect the Categories tab so tapping a category shows a filtered product list.

Connect Categories to Filtered Products

Your product detail screens work. Tap any product card and a detail view slides in from the right, complete with the image, description, price, and an Add to Cart button.

But the Categories tab is still a dead end. Those category rows have Links pointing to /category/${item}, and right now there's nothing on the other end of those links. In this step, you'll create the missing route and wire everything together so the two tabs are fully connected.

In this step, get ready to:
  • Create a dynamic route that filters products by category.
  • Register the new route in the root Stack layout.
  • Test the full navigation flow from categories to filtered products to product details and back.

Before writing any code, try tapping a category in the simulator to see what happens.

  • Switch to the Categories tab in the iOS Simulator.
  • Tap any category name, like Dairy.

You see an error or unmatched route screen. The Link navigates to /category/Dairy, but no file at src/app/category/[name].tsx exists to catch it. This is exactly the gap you'll close next.

Create the category products route

In Expo Router, a file named with square brackets like [name].tsx creates a dynamic route. Whatever value appears in that URL segment gets passed as a parameter you can read inside the component.

  • Create a new folder called category inside src/app/.
  • Create a new file called [name].tsx inside the src/app/category/ folder.
  • Add the imports and component function by copying in this code:
import { View, FlatList, StyleSheet } from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router';

import { products } from '@/data/products';
import { ProductCard } from '@/components/product-card';

export default function CategoryScreen() {
  // Read the category name from the URL parameter
  const { name } = useLocalSearchParams<{ name: string }>();

  // Filter products to only those matching this category
  const filtered = products.filter((p) => p.category === name);

  return (
    <View style={styles.container}>
      {/* Dynamically set the header title to the category name */}
      <Stack.Screen options={{ title: name }} />
      <FlatList
        data={filtered}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => <ProductCard product={item} />}
        contentContainerStyle={styles.list}
        ItemSeparatorComponent={() => <View style={styles.separator} />}
      />
    </View>
  );
}

What does this code do?

useLocalSearchParams reads the name segment from the URL. When you navigate to /category/Bakery, the name variable becomes "Bakery".

The component filters the full products array down to only items matching that category, then renders them in a FlatList using the same ProductCard component you already built. Reusing the card keeps the UI consistent across screens.

Stack.Screen options={{ title: name }} dynamically sets the header title to the category name, so the learner sees "Bakery" or "Dairy" at the top of the screen.

  • Add the styles below the component function in the same file:
const styles = StyleSheet.create({
  // Full-screen container with light background
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  // Padding around the product list
  list: {
    padding: 16,
  },
  // Spacing between product cards
  separator: {
    height: 12,
  },
});

What do these styles do?

These styles match the same layout pattern you used on the Products tab. The container fills the screen, list adds padding around the cards, and separator adds 12 points of spacing between each card. Consistent spacing across screens is what makes an app feel polished.

  • Save src/app/category/[name].tsx.
  • Switch to the Categories tab in the simulator and tap Bakery. You should see a filtered list showing only Sourdough Bread, Croissants, and Baguette.

Still seeing an error when you tap a category?

  • Make sure the file is named exactly [name].tsx with square brackets. The brackets tell Expo Router this is a dynamic route.
  • Check that the file is inside src/app/category/, not inside src/app/(tabs)/. The category screen should sit outside the tabs group so it pushes above the tab bar.
  • Try stopping the dev server with Ctrl+C and restarting with npx expo start. New route files sometimes need a server restart to be detected.

Help me fix my category route in Expo Router

✔️ Awesome, I've got everything!

Great. Make sure you've saved src/app/category/[name].tsx before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm both chunks are present in order: the imports and CategoryScreen component function first, then the StyleSheet.create block below it. Make sure there are no extra blank lines between the component closing brace and the const styles line.

Register the route in the root layout

The category screen works because Expo Router picks up any file in src/app/ as a route. However, you should explicitly register it in the root Stack so you can configure its header options.

  • Open src/app/_layout.tsx and find this line:
      <Stack.Screen name="product/[id]" options={{ title: 'Product Details' }} />
  • Add this new line directly below it:
      <Stack.Screen name="category/[name]" options={{ title: 'Category' }} />
  • The result should look like this:
      <Stack.Screen name="product/[id]" options={{ title: 'Product Details' }} />
      <Stack.Screen name="category/[name]" options={{ title: 'Category' }} />

Why register the route in the Stack?

Expo Router's file-based routing already makes the route work, but adding a Stack.Screen entry lets you configure the header. The title: 'Category' sets a fallback header title, and the component's own Stack.Screen options={{ title: name }} overrides it dynamically with the actual category name.

  • Save src/app/_layout.tsx.
  • In the simulator, tap the Categories tab and tap Dairy. The header at the top should now read Dairy instead of a generic title.

Header still showing a default title?

  • Make sure the name in Stack.Screen name="category/[name]" matches the folder and file path exactly. It should be category/[name], not categories/[name].
  • Check that <Stack.Screen options={{ title: name }} /> is inside the return block of CategoryScreen in [name].tsx.

Help me fix the dynamic header title on my category screen

✔️ Awesome, I've got everything!

Great. Double check you've saved src/app/_layout.tsx before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/app/_layout.tsx with the new category route added:

import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="product/[id]" options={{ title: 'Product Details' }} />
      <Stack.Screen name="category/[name]" options={{ title: 'Category' }} />
    </Stack>
  );
}
Test the full navigation flow

Both tabs are now connected through shared product data and Expo Router's navigation stack. Time to walk the full path and make sure every screen transition works.

Before you start testing, predict: when you tap a product from inside a filtered category list, will the tab bar be visible on the product detail screen? Why or why not?

  • Tap the Categories tab.
  • Tap Bakery. You should see only Sourdough Bread, Croissants, and Baguette. The header reads Bakery.
  • Tap any product card, like Sourdough Bread. The detail screen slides in from the right with the image, description, price, and Add to Cart button. The tab bar is hidden because this screen pushes above the (tabs) group in the root Stack.
  • Tap the back button in the header. You return to the filtered Bakery list.
  • Tap back again. You return to the Categories tab with the tab bar visible.
  • Switch to the Products tab and tap any product card. The detail screen slides in the same way. Tap back to return to the full product list.

Why does the tab bar hide on detail and category screens?

The product/[id] and category/[name] routes live outside the (tabs) folder. When you navigate to them, they push onto the root Stack, which renders above the tab bar. This is the standard iOS pattern for drill-down screens.

If you placed these routes inside (tabs)/, the tab bar would stay visible on every screen. Placing them at the root level gives you the correct hide-on-drill-down behaviour automatically.

Your two tabs are fully connected. Categories filter the product list, products drill into detail screens, and every back button returns you exactly where you started. Next up, you'll push this further in a secret mission.

Secret mission

Add a Search Bar to the Products Tab

Your product browser works, but scrolling through 12 items is easy. What happens when the list grows to hundreds of products? A search bar that filters in real time is a must-have for any product catalog. Can you wire one up using React Native's TextInput and state-driven filtering?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The ProductBrowser/ project directory on your Desktop.
  • The Expo development server running in your terminal.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with the app.

  • Your Expo dev server stays running in the terminal. Press i anytime to reopen the iOS Simulator.
  • If you close the terminal, restart the server later by navigating to ~/Desktop/ProductBrowser and running npx expo start.

Pause - I'll come back to this later

Shut down the running process to free up memory, but keep your project files so you can pick up where you left off.

  • Switch to the terminal running the Expo dev server and press Ctrl+C to stop it.
  • Close the iOS Simulator app.
  • Your ProductBrowser/ directory stays on your Desktop. When you are ready to resume, open a terminal, navigate to the project folder, and run npx expo start.

Delete - I don't want to use this again

Remove all project files and stop the running process to start fresh.

  • Switch to the terminal running the Expo dev server and press Ctrl+C to stop it.
  • Close the iOS Simulator app.
  • Delete the project directory by running this command:
rm -rf ~/Desktop/ProductBrowser
  • Confirm the folder is gone by running:
ls ~/Desktop | grep ProductBrowser

You should see no output, confirming the directory has been removed.

Nice Work!

Nice Work!

Nice work! You just built a fully functional product browser app with React Native and Expo SDK 57, running natively on the iOS Simulator.

You've learned how to:

  • Built native mobile UIs using React Native's core components (View, Text, Image, FlatList, Pressable) and StyleSheet-based styling instead of HTML and CSS.
  • Implemented multi-screen navigation with Expo Router, combining file-based tab navigation and stack navigation with dynamic routes.
  • Rendered performant scrollable product lists with FlatList and connected categories to filtered product views using dynamic route parameters.
  • Secret Mission: Added a real-time search bar with TextInput and state-driven FlatList filtering.

Ready to quiz yourself?