Project 3: Quiz App with Expo Router

React Native with Expo: 10 Real Projects · Building Real Apps · 9 min read · Updated

File-based routing

  1. Every file in app/ becomes a route automatically.
  2. The layout wraps them in a Stack navigator.
  3. router.push opens the quiz screen on top of home.
  4. At the end router.replace opens the result with the score as params.

Real apps have many screens. In Expo, the standard way to move between them is Expo Router. It uses file-based routing, like Next.js on the web: each file in the app folder becomes a screen, and its name becomes the route. It is built on React Navigation, so you get native-feeling transitions and a back button for free.

Concepts in this project

  • File-based routes: app/index.tsx is /, app/quiz.tsx is /quiz, app/[id].tsx is /123.
  • _layout.tsx: wraps the screens in a navigator. Stack pushes screens on top of each other.
  • useRouter: router.push goes forward, router.replace swaps the current screen, router.back goes back.
  • Params: send values with { pathname, params } and read them with useLocalSearchParams. Params are always strings.
  • Keep data out of `app/`: every file there is treated as a route, so put helpers in a src folder.

Set up Expo Router

A new project made with the default Expo template already uses Expo Router. If you started from the blank template, follow these steps.

package.json, app.json and folder layout

// package.json
{
  "main": "expo-router/entry"
}

// app.json  (add the scheme and the plugin)
{
  "expo": {
    "scheme": "myapp",
    "plugins": ["expo-router"]
  }
}

// File layout for expo-router: every file in app/ is a screen
app/
  _layout.tsx      navigation container (Stack or Tabs)
  index.tsx        route "/"
  quiz.tsx         route "/quiz"
  [id].tsx         dynamic route "/123"

Install the supporting libraries with the commands below (use only the lines you need now), and delete the old App.tsx so it does not conflict.

Install commands for all ten projects

# Install libraries with expo install so versions match your Expo SDK
npx expo install @react-native-async-storage/async-storage   # projects 2, 5, 8, 9
npx expo install expo-router react-native-safe-area-context react-native-screens expo-linking expo-constants expo-status-bar   # projects 3, 7, 10
npx expo install expo-sqlite                                  # project 6
npx expo install expo-image-picker expo-file-system           # project 8
npx expo install expo-notifications                           # project 9
npm install @tanstack/react-query zustand                     # projects 7, 10

Each project tells you which line to run.

my-app/
  app/
    _layout.tsx    Stack navigator
    index.tsx      home: Start button
    quiz.tsx       questions
    result.tsx     final score
  src/
    questions.ts   the quiz data

The code

`src/questions.ts`

export type Question = { text: string; options: string[]; answer: number };

export const questions: Question[] = [
  { text: "Which company created React Native?", options: ["Google", "Meta", "Apple", "Microsoft"], answer: 1 },
  { text: "Which component shows a scrollable list efficiently?", options: ["View", "Text", "FlatList", "Image"], answer: 2 },
  { text: "Which hook keeps a value between renders?", options: ["useState", "useRouter", "useId", "useContext"], answer: 0 },
  { text: "What language is used with Expo in this course?", options: ["Java", "TypeScript", "Swift", "PHP"], answer: 1 },
];

`app/_layout.tsx`

import { Stack } from "expo-router";

export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: "Quiz" }} />
      <Stack.Screen name="quiz" options={{ title: "Question", headerBackVisible: false }} />
      <Stack.Screen name="result" options={{ title: "Result", headerBackVisible: false }} />
    </Stack>
  );
}

`app/index.tsx`

import { View, Text, Pressable, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { questions } from "../src/questions";

export default function Home() {
  const router = useRouter();
  return (
    <View style={styles.screen}>
      <Text style={styles.title}>React Native Quiz</Text>
      <Text style={styles.sub}>{questions.length} questions</Text>
      <Pressable style={styles.button} onPress={() => router.push("/quiz")}>
        <Text style={styles.buttonText}>Start</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
  title: { fontSize: 30, fontWeight: "700" },
  sub: { marginVertical: 12, color: "#555" },
  button: { backgroundColor: "#3b5bdb", paddingVertical: 14, paddingHorizontal: 40, borderRadius: 12 },
  buttonText: { color: "#fff", fontSize: 18, fontWeight: "600" },
});

`app/quiz.tsx`

import { useState } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { questions } from "../src/questions";

export default function Quiz() {
  const router = useRouter();
  const [index, setIndex] = useState(0);
  const [score, setScore] = useState(0);
  const [picked, setPicked] = useState<number | null>(null);

  const q = questions[index];
  const last = index === questions.length - 1;

  const choose = (i: number) => {
    if (picked !== null) return;
    setPicked(i);
    if (i === q.answer) setScore((s) => s + 1);
  };

  const next = () => {
    if (last) {
      router.replace({ pathname: "/result", params: { score: String(score), total: String(questions.length) } });
    } else {
      setIndex(index + 1);
      setPicked(null);
    }
  };

  return (
    <View style={styles.screen}>
      <Text style={styles.count}>
        Question {index + 1} of {questions.length}
      </Text>
      <Text style={styles.question}>{q.text}</Text>
      {q.options.map((opt, i) => {
        const state = picked === null ? "" : i === q.answer ? "right" : i === picked ? "wrong" : "";
        return (
          <Pressable
            key={opt}
            onPress={() => choose(i)}
            style={[styles.option, state === "right" && styles.right, state === "wrong" && styles.wrong]}
          >
            <Text style={styles.optionText}>{opt}</Text>
          </Pressable>
        );
      })}
      {picked !== null && (
        <Pressable style={styles.next} onPress={next}>
          <Text style={styles.nextText}>{last ? "See result" : "Next"}</Text>
        </Pressable>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 24 },
  count: { color: "#555", marginBottom: 8 },
  question: { fontSize: 22, fontWeight: "600", marginBottom: 20 },
  option: { padding: 14, borderRadius: 10, backgroundColor: "#e9ecef", marginBottom: 10 },
  optionText: { fontSize: 16 },
  right: { backgroundColor: "#b2f2bb" },
  wrong: { backgroundColor: "#ffc9c9" },
  next: { marginTop: 16, backgroundColor: "#3b5bdb", padding: 14, borderRadius: 10, alignItems: "center" },
  nextText: { color: "#fff", fontWeight: "600", fontSize: 16 },
});

`app/result.tsx`

import { View, Text, Pressable, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";

export default function Result() {
  const { score, total } = useLocalSearchParams<{ score: string; total: string }>();
  const router = useRouter();
  return (
    <View style={styles.screen}>
      <Text style={styles.title}>
        You scored {score} out of {total}
      </Text>
      <Pressable style={styles.button} onPress={() => router.replace("/")}>
        <Text style={styles.buttonText}>Play again</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
  title: { fontSize: 26, fontWeight: "700", textAlign: "center", marginBottom: 24 },
  button: { backgroundColor: "#3b5bdb", paddingVertical: 14, paddingHorizontal: 36, borderRadius: 12 },
  buttonText: { color: "#fff", fontSize: 18, fontWeight: "600" },
});

How it works

  • Start calls router.push("/quiz"). The stack now holds home and quiz.
  • The quiz keeps index, score and the chosen answer in state. After the last question it calls router.replace to /result with the score as params. replace swaps the quiz out so Back does not return to it.
  • The result screen reads score and total with useLocalSearchParams and offers Play again with router.replace("/").

Expected output

The home screen shows React Native Quiz, "4 questions" and a Start button. Each question shows Question 1 of 4, the text and four options. Tapping an option turns the right answer green and a wrong choice red, and a Next button appears. After the last question you see "You scored 3 out of 4" (your number depends on your answers) and a Play again button. The back arrow is hidden on the quiz and result screens by design.

Your task

  1. Set up the router, add the files and play the quiz.
  2. Add four more questions to src/questions.ts.
  3. Show a different message for a perfect score on the result screen.
  4. Add a Settings screen reachable from home and check that the back button returns to home.

How to check it worked

  • The quiz runs from start to result.
  • Back does not return into a finished quiz.
  • You can explain push versus replace.

Frequently asked questions

Why use Expo Router instead of React Navigation directly?

Expo Router builds on React Navigation and adds file-based routes and deep links. It means less setup and URLs for every screen, which helps with links and the web.

Why are route params strings?

Params travel in the URL, so they are text. Convert them with Number() or JSON when you need another type, and pass an ID instead of a whole object.

Where do non-screen files go?

Outside the app folder, for example in src, because every file in app is treated as a screen.