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
| Decision | Choice | Why |
|---|---|---|
| Storage | expo-sqlite | Lists, sums and group-by are easy in SQL; works offline |
| Money | Integer paise (1 rupee = 100 paise) | Decimals like 0.1 + 0.2 give rounding errors; integers are exact |
| Dates | Text in YYYY-MM-DD | Sorts correctly as text and is easy to filter by month |
| Navigation | Expo Router: Stack with a Tabs group and an Add modal | File-based, as in the Navigation lesson |
| Auth and server | None in v1 | Keep it small; sync is a stretch goal |
The data model is one table:
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
npx create-expo-app@latest paisa
cd paisa
npx expo install expo-sqlite
npx expo startThe 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.
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.
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.
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.
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>
);
}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.
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.
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.
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
- 1Run
npx expo startand open the app on your phone with Expo Go (or pressafor an Android emulator). - 2The Expenses tab shows the empty message and Rs. 0.00.
- 3Tap +, add 'Filter coffee', amount 80, category Food. Save. You return to the list with one row and the total Rs. 80.00.
- 4Add 'Auto fare', 150, Travel, and 'Electricity', 1250.50, Bills.
- 5Open the Summary tab. You should see three bars, with Bills the longest.
- 6Try an empty title and the amount
abc. Each should show an error and not save. - 7Long-press a row, confirm delete, and check the total drops.
- 8Close the app fully and reopen it. Your data should still be there.
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
| Symptom | Likely cause and fix |
|---|---|
| 'No route named add' warning | The file must be app/add.tsx and listed in the root Stack with the same name |
| New expense not showing after saving | List must reload on focus: check useFocusEffect and that you call load() |
| Blank screen at start | Another 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 wrong | You stored rupees instead of paise; check parseRupeesToPaise |
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
- Plan data first: one table, integer paise for money, text dates that sort.
- Keep SQL in one module and pass values through
?placeholders. SQLiteProviderwithonInitmigrates the database and shares it with all screens.useFocusEffectreloads a screen when you return to it from a modal.- Let SQL do the maths (
SUM,GROUP BY) rather than loading everything into JavaScript. - Validate input before saving and test the pure helper functions.
- 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.