Database: your data everywhere
Chapter objectives
- Understand what a database is and how it is organized
- Migrate your app from localStorage to a real online database
- Guarantee that each user only sees their own data
The missing piece
Tom's students now log in with their email — but Lina, faithful to her post, reports that her school-library habits still aren't on her phone. Of course: authentication says who is there, but the data still lives in each device's localStorage. What's missing is a central place to store each account's habits: an online database. That's this chapter's project, and it's probably the most transformative of the course — after it, your app stops being a browser gadget and becomes a real service.
The shift in logic fits in one sentence: instead of each browser keeping its own copy of the data, all devices read and write in the same place, a database server. Your app becomes a front desk: it displays what the database says, and records every action there. Lina checks off "Reading" on her phone; the check goes into the database; the next day at the school library, the app asks the database "show me Lina's habits" and everything is there.
A database, concretely
If you've ever opened a spreadsheet, you know 80% of the concept. A database is a set of tables; each table looks like a spreadsheet with columns (the types of information) and rows (the records). Each row has a unique identifier (id), and — here's the magic part — a row can point to another: an habit's user_id column says "this habit belongs to this user". That's called a relationship, and it's what cleanly links the data to the accounts from chapter 6.
Tom's model is minimalist: a habits table (one row per habit, with its name and its owner's user_id) and a checks table (one row per checked day, linked to its habit). Two tables, four useful columns each — resist the temptation to plan ten "for later". You don't need to write what follows yourself, but learn to read it: it's the SQL language the AI will generate to create the tables.
create table habits ( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users not null, name text not null, created_at timestamptz default now() ); create table checks ( id uuid primary key default gen_random_uuid(), habit_id uuid references habits not null, day date not null );
Read it like near-English: "create a habits table; each row has a unique id, belongs to a user, carries a name, and keeps its creation date". The references auth.users line is the relationship: impossible to create an orphan habit with no owner. Always ask the AI to explain the schema it proposes — those guided readings are where you learn the most.
flowchart LR T["Lina's phone"] -->|"Checks off a habit"| B["Supabase database"] O["School library computer"] -->|"Same account"| B B -->|"Returns the same data"| T B -->|"Returns the same data"| O
Security first: everyone sees their own data
Crucial question before writing a single piece of data: what prevents Lina's app from reading Karim's habits? Answer: nothing, by default. It's up to you to demand it, through what Supabase calls Row Level Security (RLS). The principle is crystal clear: each table gets a rule like "a user can only read and modify the rows whose user_id is their own". The database itself refuses everything else, no matter what the app's code does.
Migrating without losing anything
Your existing users have data in their localStorage: throwing it away would make them pay for your upgrade. The migration plan is classic and elegant: on a user's first login, the app checks whether local data remains, and offers to import it into their account. After a successful import, the localStorage is cleaned up, and the database becomes the single source of truth. Here is Tom's complete prompt — notice that he breaks the project into steps himself:
Evolve my habit-tracking app from localStorage to Supabase. Context: magic link authentication already works (previous chapter). Steps I want, in this order: 1. create the habits and checks tables, linked to the logged-in user via user_id 2. enable Row Level Security with one rule per table: each user only reads and modifies their own rows 3. make the app read and write in the database instead of localStorage: adding, deleting, checking off, streak calculation 4. on the first login of a user who still has local data, offer to import it into their account, then clean up the localStorage Do ONE step at a time and wait for me to confirm my test before moving to the next. At each step, explain to me in two sentences what you did.
This prompt condenses everything you've learned: the full vision announced upfront (chapter 3), the breakdown into testable steps (chapter 4), security explicitly demanded, and the request for explanation that turns each step into a lesson. When the AI finishes step 1, go look at your tables in the Supabase dashboard ("Table Editor" tab): seeing your data "for real", in the service's interface, makes the whole concept concrete.
Testing persistence for real
The migration touches the heart of your app: the test must measure up. Don't settle for "it seems to work" — run through a complete scenario, with several accounts and several devices. Here is Tom's checklist:
- Persistence: check off a habit, reload the page, close the browser, come back — the check is still there.
- Multi-device: check off on your phone, reload on the computer — the check appears.
- Isolation: create two test accounts, add different habits in each, and verify that neither sees the other's data.
- Migration: on a browser that has old local data, log in and verify that the import is offered, then that everything arrived in the account.
- Logout: logged out, the app must display no data at all.
For the isolation test — the most important one — get active help. The AI is excellent at generating test scenarios smarter than yours:
I created two test accounts: student1@test.com and student2@test.com. Give me a step-by-step test scenario to verify that: - each account only sees its own habits and checks - a check made on one device appears on another device of the same account after a reload - a logged-out user sees no data Then explain how to verify directly in the Supabase dashboard that each row in habits carries the right user_id, and how to test that Row Level Security really blocks cross-account access.
Latency and loading states
A subtle change comes with the migration: your data is no longer instantaneous. With localStorage, everything was on hand; with an online database, every read and every write makes a network round trip of a few dozen or hundred milliseconds. Most of the time it's imperceptible — but on a slow connection, a list that takes a second to display with no indicator at all feels like a broken app. Ask the AI to add loading states ("a small indicator while the habits load") and a clear message if the network fails ("can't reach the server, try again").
It's also the moment to learn a pro reflex: testing your app while simulating a bad network. In the browser's developer tools (F12), the "Network" tab lets you throttle the connection ("Slow 3G"). Thirty seconds of testing in those conditions shows you exactly what your least-well-connected users will experience — and what needs improving.
What Tom just gained
Measure the distance traveled: Tom's app now has accounts, centralized data, secured row by row, and synchronized across all devices. Lina checks off at the school library, checks her streak on the bus, and everything is consistent. But there's a more discreet gain: Tom now has a real backend. All the previously "impossible" features — sending emails, accepting a payment, plugging in AI — become reachable, because they finally have something to stand on. That's exactly the agenda of the next chapter.
Context
Tom dedicates his weekend to the migration: it's the most structuring project since the start, and he wants to do it properly — tables, security, migration of existing data, multi-account tests. Follow the same path on your app, step by step, never moving forward before validating the current step. By the end, your data will follow you everywhere.
Instructions
- Describe your data model to the AI in plain language ("one table for…, one table for…") and ask for the commented SQL schema.
- Send the migration prompt, explicitly demanding Row Level Security, one step at a time.
- After the tables step, open the Supabase dashboard and verify their structure in the Table Editor.
- Run the full checklist: persistence, multi-device, isolation between two test accounts, migration of local data.
- Ask for loading states and a network error message, then test in "Slow 3G" mode via F12.
- Final commit: "database migration complete and tested" — it’s a major milestone of your project.
In summary
- An online database is the central place where all devices read and write the same data.
- Tables, columns, rows: a database looks like a spreadsheet, with unique identifiers and relationships.
- The user_id column links each piece of data to its owner — it’s the bridge to chapter 6’s authentication.
- Row Level Security is non-negotiable: without it, every user can technically read everyone else’s data.
- Migrate without loss: offer to import local data on first login, then clean up the localStorage.
- Test seriously: persistence, multi-device, isolation between accounts, and behavior on a slow network.
- With a real backend, advanced features (emails, payment, AI) finally become reachable.
Quiz — check your understanding
1. Which problem does the database solve that authentication alone didn’t?
2. What is the user_id column for in the habits table?
3. What happens if you forget to enable Row Level Security?
4. How do you migrate existing users’ localStorage data without losing it?
5. After the migration, the list shows up empty even though the database does contain rows. What is the most likely cause?