Build a minimal timer sequence app with React and SQLite
Welcome to another official UVF IT build guide. Today we will construct a local-first routine scheduler inspired by TimerVana, focusing on the core mechanic of sequential timers without the complexity of cloud sync.
This project uses a lightweight stack: React for the UI and a simple Node.js backend with SQLite for persistence. You will learn how to structure a sequence of tasks, handle state transitions, and persist data locally in under 45 minutes.
Initialize a monorepo structure with a frontend and backend directory. We will use Vite for the React app and Express for the API to keep the build process fast and modular.
Install necessary dependencies: react, vite, express, better-sqlite3, and cors. This ensures we have the tools needed for the UI, the server, the database, and cross-origin resource sharing.
mkdir routine-scheduler && cd routine-scheduler
mkdir server client
cd client && npm create vite@latest . -- --template react && npm install
cd ../server && npm init -y && npm install express better-sqlite3 corsSet up the SQLite database to store 'Routines' and 'Steps'. A Routine has a name, and Steps have a duration and label, allowing us to define sequences of tasks.
Create a simple initialization script to set up the tables if they don't exist. This script will run automatically when the server starts, ensuring the database schema is always ready.
server/db.js
const Database = require('better-sqlite3');
const path = require('path');
const db = new Database(path.join(__dirname, 'routines.db'));
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS routines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
routine_id INTEGER NOT NULL,
label TEXT NOT NULL,
duration_seconds INTEGER NOT NULL,
order_index INTEGER NOT NULL,
FOREIGN KEY (routine_id) REFERENCES routines(id) ON DELETE CASCADE
);
`);
module.exports = db;Build the Express API to handle CRUD operations for routines and steps. This includes endpoints to create, read, update, and delete routines and their associated steps.
Create a basic React component to display a list of routines and allow adding new ones. This frontend will communicate with the backend API to fetch and display data.
server/index.js
const express = require('express');
const cors = require('cors');
const db = require('./db');
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json());
// Get all routines with their steps
app.get('/api/routines', (req, res) => {
const routines = db.prepare('SELECT * FROM routines').all();
const routinesWithSteps = routines.map(routine => {
const steps = db.prepare('SELECT * FROM steps WHERE routine_id = ? ORDER BY order_index').all(routine.id);
return { ...routine, steps };
});
res.json(routinesWithSteps);
});
// Create a new routine
app.post('/api/routines', (req, res) => {
const { name } = req.body;
const stmt = db.prepare('INSERT INTO routines (name) VALUES (?)');
const result = stmt.run(name);
res.status(201).json({ id: result.lastInsertRowid, name });
});
// Add a step to a routine
app.post('/api/routines/:id/steps', (req, res) => {
const { label, duration_seconds } = req.body;
const routineId = req.params.id;
const maxOrder = db.prepare('SELECT MAX(order_index) FROM steps WHERE routine_id = ?').get(routineId)?.[0] ?? -1;
const newOrder = maxOrder + 1;
const stmt = db.prepare('INSERT INTO steps (routine_id, label, duration_seconds, order_index) VALUES (?, ?, ?, ?)');
const result = stmt.run(routineId, label, duration_seconds, newOrder);
res.status(201).json({ id: result.lastInsertRowid, routine_id: routineId, label, duration_seconds, order_index: newOrder });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});client/src/App.jsx
import { useState, useEffect } from 'react';
function App() {
const [routines, setRoutines] = useState([]);
const [newRoutineName, setNewRoutineName] = useState('');
useEffect(() => {
fetchRoutines();
}, []);
const fetchRoutines = async () => {
const response = await fetch('http://localhost:3001/api/routines');
const data = await response.json();
setRoutines(data);
};
const addRoutine = async (e) => {
e.preventDefault();
if (!newRoutineName.trim()) return;
await fetch('http://localhost:3001/api/routines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newRoutineName }),
});
setNewRoutineName('');
fetchRoutines();
};
return (
<div style={{ padding: '20px' }}>
<h1>Local-First Routine Scheduler</h1>
<form onSubmit={addRoutine}>
<input
type="text"
value={newRoutineName}
onChange={(e) => setNewRoutineName(e.target.value)}
placeholder="New Routine Name"
/>
<button type="submit">Add Routine</button>
</form>
<ul>
{routines.map((routine) => (
<li key={routine.id}>
<strong>{routine.name}</strong>
<ul>
{routine.steps.map((step) => (
<li key={step.id}>{step.label} - {step.duration_seconds}s</li>
))}
</ul>
</li>
))}
</ul>
</div>
);
}
export default App;With the code structured, it is time to bring the application to life by starting both the backend and frontend services. You will run the Express server in one terminal window and the Vite development server in another to simulate a real development environment.
The backend listens on port 3001 to handle API requests, while the frontend runs on port 5173 by default. Ensure both terminals are active so the React app can successfully fetch data from your local SQLite database.
# Terminal 1: Start the Backend Server
cd server
node index.js
# Terminal 2: Start the Frontend Dev Server
cd client
npm run devOpen your browser and navigate to http://localhost:5173 to interact with your new scheduler. You should see the initial UI where you can input a routine name and create a new entry.
Test the persistence by adding a routine and its steps, then refresh the page. If the data remains visible after the reload, your SQLite integration is working correctly. This confirms that your local-first architecture is successfully storing and retrieving state.
# Verify the database file was created and contains data
sqlite3 server/data.db "SELECT * FROM routines;"
sqlite3 server/data.db "SELECT * FROM steps;"| Check | Command / Why it matters |
|---|---|
| Backend starts without errors | node server/index.js |
| Frontend loads and connects to API | Open http://localhost:5173 in browser |
| Routine creation persists | Add a routine, refresh page, ensure it remains |
| Steps are ordered correctly | Add multiple steps, verify order_index in DB |
You now have a functional local-first scheduler. To extend this, consider adding a timer component that counts down each step, or implementing a 'pause' feature that saves the current state to the database.