Mobile App Development
ProjectIntermediate 60 min· Ship it · 11 of 12

Project: expense tracker app

Build Paisa, an offline expense tracker with Expo Router, SQLite, an add form with validation and a monthly summary by category. Then extend it and ship it to your phone.

You will build Paisa, a personal expense tracker that works fully offline. Users add expenses (title, amount in rupees, category), see them in a list grouped by newest first, delete mistakes, and open a Summary tab showing this month's total and spending by category. Data is stored on the phone in SQLite, so it survives closing the app and needs no server or login.

Finished result: two tabs (Expenses and Summary), a floating Add button that opens a form, and a Rs. total at the top of each tab.

Prerequisites: *Start an app with Expo*, *Building screens*, *Navigation with Expo Router* and *Offline support and performance* in this track. Time: about 90 minutes. This project uses Expo SDK 53 or newer and TypeScript.

Plan and design decisions

Design

DecisionChoiceWhy
Storageexpo-sqliteLists, sums and group-by are easy in SQL; works offline
MoneyInteger paise (1 rupee = 100 paise)Decimals like 0.1 + 0.2 give rounding errors; integers are exact
DatesText in YYYY-MM-DDSorts correctly as text and is easy to filter by month
NavigationExpo Router: Stack with a Tabs group and an Add modalFile-based, as in the Navigation lesson
Auth and serverNone in v1Keep it small; sync is a stretch goal

The data model is one table:

Schema sketch
expenses
  id            INTEGER  primary key, auto-increment
  title         TEXT     e.g. 'Filter coffee'
  amount_paise  INTEGER  e.g. 8000 for Rs. 80.00
  category      TEXT     Food, Travel, Bills, Shopping, Other
  spent_on      TEXT     '2026-10-31'

Stage 1: Create the project

Terminal
npx create-expo-app@latest paisa
cd paisa
npx expo install expo-sqlite
npx expo start

The default template already includes Expo Router. It has sample files in app/ and sample folders such as app-example or (tabs); you will replace them.

The default template contains example screens in app/. Delete everything inside app/ (keep the folder) so we start clean. If your template has a scripts/reset-project.js, you can run npm run reset-project instead, which moves the examples away and leaves a blank app. Then create the files below.

Prompt · Ask AI for a project skeleton (optional)
I am building an offline expense tracker in Expo (SDK 53+, TypeScript, Expo Router, expo-sqlite). Screens: an Expenses list tab, a Summary tab, and an Add expense modal. Money is stored as integer paise. List the files I need under app/ and src/ with one line on what each does. Do not write the code yet.

What to expect: Check that it proposes a Tabs group inside a Stack, a database module, and a modal route for adding. If it suggests a server or a login, tell it to remove them for v1.

Stage 2: Database and helpers

We keep all SQL in one file so screens stay simple. migrate runs once when the database opens. The SQLiteProvider (next stage) calls it. The PRAGMA user_version check makes it easy to add new columns in future versions: check the number, run the changes, then raise it.

src/db.ts
import type { SQLiteDatabase } from 'expo-sqlite';

export const CATEGORIES = ['Food', 'Travel', 'Bills', 'Shopping', 'Other'] as const;
export type Category = (typeof CATEGORIES)[number];

export type Expense = {
  id: number;
  title: string;
  amount_paise: number;
  category: Category;
  spent_on: string; // YYYY-MM-DD
};

const DB_VERSION = 1;

export async function migrate(db: SQLiteDatabase) {
  const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
  const current = row?.user_version ?? 0;
  if (current >= DB_VERSION) return;

  if (current === 0) {
    await db.execAsync(`
      PRAGMA journal_mode = WAL;
      CREATE TABLE IF NOT EXISTS expenses (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        amount_paise INTEGER NOT NULL,
        category TEXT NOT NULL,
        spent_on TEXT NOT NULL
      );
    `);
  }
  await db.execAsync(`PRAGMA user_version = ${DB_VERSION}`);
}

export function listExpenses(db: SQLiteDatabase) {
  return db.getAllAsync<Expense>('SELECT * FROM expenses ORDER BY spent_on DESC, id DESC');
}

export function addExpense(
  db: SQLiteDatabase,
  e: { title: string; amount_paise: number; category: Category; spent_on: string }
) {
  return db.runAsync(
    'INSERT INTO expenses (title, amount_paise, category, spent_on) VALUES (?, ?, ?, ?)',
    e.title,
    e.amount_paise,
    e.category,
    e.spent_on
  );
}

export function deleteExpense(db: SQLiteDatabase, id: number) {
  return db.runAsync('DELETE FROM expenses WHERE id = ?', id);
}

export function monthTotals(db: SQLiteDatabase, month: string) {
  // month is 'YYYY-MM'
  return db.getAllAsync<{ category: string; total: number }>(
    `SELECT category, SUM(amount_paise) AS total
       FROM expenses
      WHERE substr(spent_on, 1, 7) = ?
      GROUP BY category
      ORDER BY total DESC`,
    month
  );
}

Always pass user input through the ? placeholders, never by joining strings into SQL. That prevents SQL injection.

src/format.ts
export function formatRupees(paise: number) {
  const rupees = paise / 100;
  return 'Rs. ' + rupees.toLocaleString('en-IN', {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  });
}

// '250' or '250.5' -> 25000 / 25050 paise. Returns null if invalid.
export function parseRupeesToPaise(text: string): number | null {
  const clean = text.trim();
  if (!/^\d+(\.\d{1,2})?$/.test(clean)) return null;
  const paise = Math.round(parseFloat(clean) * 100);
  return paise > 0 ? paise : null;
}

function pad(n: number) {
  return String(n).padStart(2, '0');
}

// Local date (not UTC) so late-night entries land on the right day in India
export function todayString(d = new Date()) {
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}

export function monthString(d = new Date()) {
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
}

toLocaleString with en-IN groups digits the Indian way (1,00,000). On some Android builds without full Intl support the grouping may differ; the amounts are still correct.

Stage 3: Root layout and navigation

SQLiteProvider opens the database, runs migrate, and makes it available to every screen through useSQLiteContext(). The root is a Stack. Its first screen is the (tabs) group, and add is presented as a modal.

app/_layout.tsx
import { Stack } from 'expo-router';
import { SQLiteProvider } from 'expo-sqlite';
import { migrate } from '../src/db';

export default function RootLayout() {
  return (
    <SQLiteProvider databaseName="paisa.db" onInit={migrate}>
      <Stack>
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
        <Stack.Screen name="add" options={{ presentation: 'modal', title: 'Add expense' }} />
      </Stack>
    </SQLiteProvider>
  );
}
app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';

export default function TabsLayout() {
  return (
    <Tabs screenOptions={{ tabBarActiveTintColor: '#2e7d32' }}>
      <Tabs.Screen name="index" options={{ title: 'Expenses' }} />
      <Tabs.Screen name="summary" options={{ title: 'Summary' }} />
    </Tabs>
  );
}

Stage 4: The expense list

The list reloads every time the tab gets focus using useFocusEffect, so after you add an expense in the modal and come back, the new row is already there. We also show the total of all expenses at the top and let the user delete by long-pressing a row, after confirming.

app/(tabs)/index.tsx
import { useCallback, useState } from 'react';
import { Alert, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect, useRouter } from 'expo-router';
import { useSQLiteContext } from 'expo-sqlite';
import { deleteExpense, Expense, listExpenses } from '../../src/db';
import { formatRupees } from '../../src/format';

export default function ExpensesScreen() {
  const db = useSQLiteContext();
  const router = useRouter();
  const [items, setItems] = useState<Expense[]>([]);

  const load = useCallback(async () => {
    setItems(await listExpenses(db));
  }, [db]);

  useFocusEffect(
    useCallback(() => {
      load();
    }, [load])
  );

  function confirmDelete(item: Expense) {
    Alert.alert('Delete expense', `Delete "${item.title}"?`, [
      { text: 'Cancel', style: 'cancel' },
      {
        text: 'Delete',
        style: 'destructive',
        onPress: async () => {
          await deleteExpense(db, item.id);
          load();
        },
      },
    ]);
  }

  const total = items.reduce((sum, e) => sum + e.amount_paise, 0);

  return (
    <View style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.headerLabel}>All time</Text>
        <Text style={styles.headerTotal}>{formatRupees(total)}</Text>
      </View>

      <FlatList
        data={items}
        keyExtractor={(e) => String(e.id)}
        ListEmptyComponent={<Text style={styles.empty}>No expenses yet. Tap + to add one.</Text>}
        renderItem={({ item }) => (
          <Pressable style={styles.row} onLongPress={() => confirmDelete(item)}>
            <View style={{ flex: 1 }}>
              <Text style={styles.title}>{item.title}</Text>
              <Text style={styles.meta}>
                {item.category} - {item.spent_on}
              </Text>
            </View>
            <Text style={styles.amount}>{formatRupees(item.amount_paise)}</Text>
          </Pressable>
        )}
      />

      <Pressable style={styles.fab} onPress={() => router.push('/add')}>
        <Text style={styles.fabText}>+</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff' },
  header: { padding: 20, backgroundColor: '#e8f5e9' },
  headerLabel: { color: '#555' },
  headerTotal: { fontSize: 28, fontWeight: '700' },
  empty: { textAlign: 'center', marginTop: 40, color: '#777' },
  row: { flexDirection: 'row', padding: 16, borderBottomWidth: 1, borderBottomColor: '#eee' },
  title: { fontSize: 16, fontWeight: '600' },
  meta: { color: '#777', marginTop: 2 },
  amount: { fontSize: 16, fontWeight: '600' },
  fab: {
    position: 'absolute',
    right: 20,
    bottom: 24,
    width: 56,
    height: 56,
    borderRadius: 28,
    backgroundColor: '#2e7d32',
    alignItems: 'center',
    justifyContent: 'center',
    elevation: 4,
  },
  fabText: { color: '#fff', fontSize: 30, marginTop: -2 },
});

Stage 5: The Add form

Validation matters because bad data ends up in the database. The title must not be empty, and the amount must parse to a positive number of paise. Category is chosen from fixed chips.

app/add.tsx
import { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useRouter } from 'expo-router';
import { useSQLiteContext } from 'expo-sqlite';
import { addExpense, CATEGORIES, Category } from '../src/db';
import { parseRupeesToPaise, todayString } from '../src/format';

export default function AddScreen() {
  const db = useSQLiteContext();
  const router = useRouter();
  const [title, setTitle] = useState('');
  const [amount, setAmount] = useState('');
  const [category, setCategory] = useState<Category>('Food');
  const [error, setError] = useState('');

  async function save() {
    const paise = parseRupeesToPaise(amount);
    if (!title.trim()) return setError('Enter a title.');
    if (paise === null) return setError('Enter a valid amount, like 80 or 249.50.');
    await addExpense(db, {
      title: title.trim(),
      amount_paise: paise,
      category,
      spent_on: todayString(),
    });
    router.back();
  }

  return (
    <ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
      <Text style={styles.label}>Title</Text>
      <TextInput style={styles.input} value={title} onChangeText={setTitle} placeholder="Filter coffee" />

      <Text style={styles.label}>Amount (Rs.)</Text>
      <TextInput
        style={styles.input}
        value={amount}
        onChangeText={setAmount}
        placeholder="80"
        keyboardType="decimal-pad"
      />

      <Text style={styles.label}>Category</Text>
      <View style={styles.chips}>
        {CATEGORIES.map((c) => (
          <Pressable
            key={c}
            onPress={() => setCategory(c)}
            style={[styles.chip, c === category && styles.chipActive]}
          >
            <Text style={c === category ? styles.chipTextActive : undefined}>{c}</Text>
          </Pressable>
        ))}
      </View>

      {error ? <Text style={styles.error}>{error}</Text> : null}

      <Pressable style={styles.save} onPress={save}>
        <Text style={styles.saveText}>Save</Text>
      </Pressable>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { padding: 20, gap: 8 },
  label: { marginTop: 8, fontWeight: '600' },
  input: { borderWidth: 1, borderColor: '#bbb', borderRadius: 8, padding: 12, fontSize: 16 },
  chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
  chip: { borderWidth: 1, borderColor: '#bbb', borderRadius: 20, paddingVertical: 8, paddingHorizontal: 14 },
  chipActive: { backgroundColor: '#2e7d32', borderColor: '#2e7d32' },
  chipTextActive: { color: '#fff' },
  error: { color: '#b00020', marginTop: 8 },
  save: { backgroundColor: '#2e7d32', borderRadius: 8, padding: 14, alignItems: 'center', marginTop: 16 },
  saveText: { color: '#fff', fontWeight: '700', fontSize: 16 },
});

Stage 6: The Summary tab

This screen asks SQLite to do the maths: one GROUP BY query gives the total per category for the current month. Each category gets a simple bar whose width is its share of the month's total.

app/(tabs)/summary.tsx
import { useCallback, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect } from 'expo-router';
import { useSQLiteContext } from 'expo-sqlite';
import { monthTotals } from '../../src/db';
import { formatRupees, monthString } from '../../src/format';

export default function SummaryScreen() {
  const db = useSQLiteContext();
  const [rows, setRows] = useState<{ category: string; total: number }[]>([]);

  useFocusEffect(
    useCallback(() => {
      monthTotals(db, monthString()).then(setRows);
    }, [db])
  );

  const grand = rows.reduce((s, r) => s + r.total, 0);

  return (
    <ScrollView contentContainerStyle={styles.container}>
      <Text style={styles.label}>This month</Text>
      <Text style={styles.total}>{formatRupees(grand)}</Text>

      {rows.length === 0 && <Text style={styles.empty}>Nothing spent this month yet.</Text>}

      {rows.map((r) => (
        <View key={r.category} style={styles.item}>
          <View style={styles.itemTop}>
            <Text style={styles.cat}>{r.category}</Text>
            <Text>{formatRupees(r.total)}</Text>
          </View>
          <View style={styles.track}>
            <View style={[styles.bar, { width: `${(r.total / grand) * 100}%` }]} />
          </View>
        </View>
      ))}
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { padding: 20, gap: 14 },
  label: { color: '#555' },
  total: { fontSize: 30, fontWeight: '700' },
  empty: { color: '#777', marginTop: 20 },
  item: { gap: 6 },
  itemTop: { flexDirection: 'row', justifyContent: 'space-between' },
  cat: { fontWeight: '600' },
  track: { height: 10, borderRadius: 5, backgroundColor: '#eee', overflow: 'hidden' },
  bar: { height: 10, backgroundColor: '#2e7d32' },
});

Run and test

  1. 1Run npx expo start and open the app on your phone with Expo Go (or press a for an Android emulator).
  2. 2The Expenses tab shows the empty message and Rs. 0.00.
  3. 3Tap +, add 'Filter coffee', amount 80, category Food. Save. You return to the list with one row and the total Rs. 80.00.
  4. 4Add 'Auto fare', 150, Travel, and 'Electricity', 1250.50, Bills.
  5. 5Open the Summary tab. You should see three bars, with Bills the longest.
  6. 6Try an empty title and the amount abc. Each should show an error and not save.
  7. 7Long-press a row, confirm delete, and check the total drops.
  8. 8Close the app fully and reopen it. Your data should still be there.
Output
Expenses tab: total Rs. 1,480.50 (80 + 150 + 1250.50) with three rows.
Summary tab: This month Rs. 1,480.50; Bills Rs. 1,250.50, Travel Rs. 150.00, Food Rs. 80.00.

When something goes wrong

Troubleshooting

SymptomLikely cause and fix
'No route named add' warningThe file must be app/add.tsx and listed in the root Stack with the same name
New expense not showing after savingList must reload on focus: check useFocusEffect and that you call load()
Blank screen at startAnother app/index.tsx from the template conflicts with (tabs)/index.tsx; delete the template files
'useSQLiteContext must be used within a SQLiteProvider'A screen is rendered outside the provider; keep the provider in app/_layout.tsx
Amount shows wrongYou stored rupees instead of paise; check parseRupeesToPaise
Prompt · Debug with AI
I'm building an Expo (SDK [version]) expense tracker with expo-sqlite and Expo Router. Problem: [what you expected] but [what happens]. Here is the error text: [paste error]. Here is the file: [paste file]. Explain the likely cause in plain words, then show the smallest fix.

What to expect: A good answer points to a specific line. Check the fix against the Expo docs for your SDK before pasting, since older APIs (like openDatabase) are different from the current async ones.

Test the logic

The money helpers are pure functions, so they are easy to test. Add Jest tests (the default Expo template can use jest-expo, see Expo's unit testing guide) for parseRupeesToPaise: '80' gives 8000, '249.50' gives 24950, '0' gives null, '-5' gives null, 'abc' gives null, '1.234' gives null. Testing is covered in the Testing track.

Stretch goals

Try it: Edit an expense

Tap a row to open the Add screen with the existing values filled in, and update the row with an UPDATE query instead of inserting a new one. Pass the id as a route parameter and read it with useLocalSearchParams.

Try it: Choose the date and month

Add a date field (type it as YYYY-MM-DD, or use a date picker package) and let the Summary tab move to previous months with arrow buttons.

Try it: Export and backup

Add a button that exports all expenses to a CSV file and opens the share sheet, using expo-file-system and expo-sharing.

Try it: Sync to a server

Create a FastAPI endpoint that accepts expenses and add a synced column plus the sync function from the Offline lesson. Add login with the Login lesson so each user only sees their own data.

Try it: Publish it

Follow *Build and publish to the Play Store* to build an APK, install it on your phone, and use it for a week.

Key takeaways

  1. Plan data first: one table, integer paise for money, text dates that sort.
  2. Keep SQL in one module and pass values through ? placeholders.
  3. SQLiteProvider with onInit migrates the database and shares it with all screens.
  4. useFocusEffect reloads a screen when you return to it from a modal.
  5. Let SQL do the maths (SUM, GROUP BY) rather than loading everything into JavaScript.
  6. Validate input before saving and test the pure helper functions.
  7. Test the whole flow, including closing and reopening the app, then ship a build to a real phone.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why store money as integer paise instead of a decimal number?

2. After saving on the Add modal the list does not show the new row. What is the most likely fix?

3. Which line is safe against SQL injection?

4. Why does the Summary use one SQL GROUP BY query?

5. What does onInit={migrate} on SQLiteProvider do?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free