Skip to main content
RapidDev - Software Development Agency
how-to-build-replit30-60 minutes

How to Build a Habit Tracker with Replit

Build a habit tracker with streaks in Replit in 30-60 minutes. Users log daily completions, see their current streak and longest streak, and visualize 90-day progress on a GitHub-style contribution heatmap. Streak calculations run via a PostgreSQL function that updates a cache table on every completion toggle. Uses Express, PostgreSQL with Drizzle ORM, and Replit Auth.

What you'll build

  • Habit management with name, color, frequency (daily/weekdays/weekends/weekly), and optional reminder times
  • Daily completion toggle that inserts or deletes a completion row based on current state
  • PostgreSQL function that calculates current streak and longest streak from completion history
  • Streak cache table that stores pre-calculated stats for fast dashboard loading
  • 90-day heatmap data endpoint returning completion counts by date for calendar visualization
  • Dashboard summary showing total habits, habits completed today, and overall completion rate
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner14 min read30-60 minutesReplit FreeApril 2026RapidDev Engineering Team
TL;DR

Build a habit tracker with streaks in Replit in 30-60 minutes. Users log daily completions, see their current streak and longest streak, and visualize 90-day progress on a GitHub-style contribution heatmap. Streak calculations run via a PostgreSQL function that updates a cache table on every completion toggle. Uses Express, PostgreSQL with Drizzle ORM, and Replit Auth.

What you're building

Habit trackers work because streaks create accountability. Seeing your 30-day streak is strong motivation not to miss a day. This project builds the core mechanic — daily completion with streak calculation — in under an hour using Replit Agent.

Replit Agent generates the Express backend from a single prompt: habits table, completions table, and a streak_cache table for pre-calculated stats. The streak calculation runs as a PostgreSQL function called on every completion toggle, updating the cache atomically. This means the dashboard always shows accurate streaks without recalculating on every page load.

The 90-day heatmap endpoint returns completion counts grouped by date — perfect for rendering a GitHub-style contribution graph in the frontend. No complex queries needed: a simple GROUP BY date with LEFT JOIN generates all the data. Deploy on Autoscale — habit trackers have brief daily check-in sessions, and scale-to-zero keeps costs at zero between uses.

Final result

A habit tracker with streak gamification, a 90-day heatmap, and today's completion dashboard — all running in your Replit account at zero monthly cost.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth

Prerequisites

  • A Replit account (free tier is sufficient)
  • No external API keys required — everything runs on Replit's built-in PostgreSQL
  • No coding experience needed — Replit Agent generates all the code

Build steps

1

Generate the project with Replit Agent

The streak_cache table is the key design insight. Without it, every dashboard load would recalculate streaks from the entire completion history. With it, streak stats are always one SELECT away.

prompt.txt
1// Prompt to type into Replit Agent:
2// Build a habit tracker app with Express and PostgreSQL using Drizzle ORM.
3// Create these tables in shared/schema.ts:
4// - habits: id serial pk, user_id text not null, name text not null,
5// description text, color text not null default '#3B82F6',
6// frequency text default 'daily' (daily/weekdays/weekends/weekly),
7// target_count integer default 1,
8// reminder_time text (format 'HH:MM', nullable),
9// is_archived boolean default false, created_at timestamp
10// - completions: id serial pk, habit_id integer references habits not null,
11// completed_date date not null, count integer default 1,
12// notes text, created_at timestamp,
13// UNIQUE constraint on (habit_id, completed_date)
14// - streak_cache: id serial pk, habit_id integer references habits not null unique,
15// current_streak integer default 0, longest_streak integer default 0,
16// last_completed date, total_completions integer default 0,
17// updated_at timestamp
18// Create a PostgreSQL function calculate_streak(p_habit_id integer)
19// that computes current_streak from completions table and upserts into streak_cache.
20// Set up Replit Auth. Bind server to 0.0.0.0.

Pro tip: The streak calculation is the hardest part for Agent. If the generated function doesn't look correct, paste the function body into the next prompt and ask Agent to fix the streak logic specifically.

Expected result: Agent creates shared/schema.ts with three tables and a PostgreSQL function. Verify in Drizzle Studio (database icon in sidebar) that the streak_cache and completions tables exist.

2

Build the habit CRUD and completion toggle routes

The completion toggle is the most-used route. It checks if today's completion exists, inserts it if not (marking as complete), or deletes it if it does (un-marking). After toggling, it calls the streak calculation function.

server/routes/habits.js
1const { db } = require('../db');
2const { habits, completions, streakCache } = require('../../shared/schema');
3const { eq, and, sql } = require('drizzle-orm');
4
5router.get('/api/habits', async (req, res) => {
6 if (!req.user) return res.status(401).json({ error: 'Auth required' });
7
8 const today = new Date().toISOString().split('T')[0];
9
10 const result = await db.execute(
11 sql`SELECT h.id, h.name, h.description, h.color, h.frequency, h.target_count, h.reminder_time,
12 sc.current_streak, sc.longest_streak, sc.total_completions, sc.last_completed,
13 CASE WHEN c.habit_id IS NOT NULL THEN true ELSE false END AS completed_today
14 FROM habits h
15 LEFT JOIN streak_cache sc ON sc.habit_id = h.id
16 LEFT JOIN completions c ON c.habit_id = h.id AND c.completed_date = ${today}
17 WHERE h.user_id = ${req.user.id} AND h.is_archived = false
18 ORDER BY h.created_at ASC`
19 );
20
21 res.json(result.rows);
22});
23
24router.post('/api/habits/:id/complete', async (req, res) => {
25 if (!req.user) return res.status(401).json({ error: 'Auth required' });
26
27 const habitId = Number(req.params.id);
28 const dateStr = req.body.date || new Date().toISOString().split('T')[0];
29
30 // Verify ownership
31 const habit = await db.query.habits.findFirst({
32 where: and(eq(habits.id, habitId), eq(habits.userId, req.user.id))
33 });
34 if (!habit) return res.status(404).json({ error: 'Habit not found' });
35
36 const existing = await db.query.completions.findFirst({
37 where: and(eq(completions.habitId, habitId), eq(completions.completedDate, dateStr))
38 });
39
40 let completed;
41 if (existing) {
42 await db.delete(completions).where(eq(completions.id, existing.id));
43 completed = false;
44 } else {
45 await db.insert(completions).values({
46 habitId, completedDate: dateStr, notes: req.body.notes || null
47 });
48 completed = true;
49 }
50
51 // Recalculate streak and update cache
52 await db.execute(sql`SELECT calculate_streak(${habitId})`);
53
54 const [cache] = await db.select().from(streakCache).where(eq(streakCache.habitId, habitId));
55
56 res.json({ completed, currentStreak: cache?.currentStreak || 0, longestStreak: cache?.longestStreak || 0 });
57});

Pro tip: The completion toggle pattern (check → insert/delete) avoids needing a separate PUT endpoint. Two taps on the same habit in the same day first completes it, then un-completes it — intuitive for mobile use.

Expected result: Tapping a habit's complete button returns {completed: true, currentStreak: 1}. Tapping again returns {completed: false, currentStreak: 0}. The streak_cache table updates each time.

3

Build the streak calculation PostgreSQL function

The streak function is the core algorithm. It counts consecutive completed dates backward from today (or the most recent completion). The frequency setting determines whether weekends count for daily habits.

prompt.txt
1// Prompt to type into Replit Agent:
2// Add this PostgreSQL function to the database migration.
3// Create or replace the calculate_streak function:
4//
5// CREATE OR REPLACE FUNCTION calculate_streak(p_habit_id INTEGER)
6// RETURNS VOID AS $$
7// DECLARE
8// v_current_streak INTEGER := 0;
9// v_longest_streak INTEGER := 0;
10// v_temp_streak INTEGER := 0;
11// v_last_date DATE := NULL;
12// v_check_date DATE;
13// v_total INTEGER;
14// v_row RECORD;
15// BEGIN
16// -- Count total completions
17// SELECT COUNT(*) INTO v_total FROM completions WHERE habit_id = p_habit_id;
18//
19// -- Calculate current streak (consecutive days ending today or yesterday)
20// v_check_date := CURRENT_DATE;
21// LOOP
22// IF EXISTS (SELECT 1 FROM completions WHERE habit_id = p_habit_id AND completed_date = v_check_date) THEN
23// v_current_streak := v_current_streak + 1;
24// v_check_date := v_check_date - INTERVAL '1 day';
25// ELSE
26// EXIT;
27// END IF;
28// END LOOP;
29//
30// -- If today not completed, check if streak continues from yesterday
31// IF v_current_streak = 0 THEN
32// v_check_date := CURRENT_DATE - INTERVAL '1 day';
33// LOOP
34// IF EXISTS (SELECT 1 FROM completions WHERE habit_id = p_habit_id AND completed_date = v_check_date) THEN
35// v_current_streak := v_current_streak + 1;
36// v_check_date := v_check_date - INTERVAL '1 day';
37// ELSE
38// EXIT;
39// END IF;
40// END LOOP;
41// END IF;
42//
43// -- Calculate longest streak from all completions
44// v_temp_streak := 0;
45// v_last_date := NULL;
46// FOR v_row IN SELECT completed_date FROM completions WHERE habit_id = p_habit_id ORDER BY completed_date ASC
47// LOOP
48// IF v_last_date IS NULL OR v_row.completed_date = v_last_date + INTERVAL '1 day' THEN
49// v_temp_streak := v_temp_streak + 1;
50// v_longest_streak := GREATEST(v_longest_streak, v_temp_streak);
51// ELSE
52// v_temp_streak := 1;
53// END IF;
54// v_last_date := v_row.completed_date;
55// END LOOP;
56//
57// -- Upsert streak_cache
58// INSERT INTO streak_cache (habit_id, current_streak, longest_streak, total_completions, last_completed, updated_at)
59// VALUES (p_habit_id, v_current_streak, v_longest_streak, v_total,
60// (SELECT MAX(completed_date) FROM completions WHERE habit_id = p_habit_id), NOW())
61// ON CONFLICT (habit_id) DO UPDATE SET
62// current_streak = EXCLUDED.current_streak,
63// longest_streak = EXCLUDED.longest_streak,
64// total_completions = EXCLUDED.total_completions,
65// last_completed = EXCLUDED.last_completed,
66// updated_at = NOW();
67// END;
68// $$ LANGUAGE plpgsql;
69//
70// Run this function in the database migration script.

Expected result: After running the migration, test the function in Drizzle Studio SQL editor: SELECT calculate_streak(1). Then add completions for several consecutive dates and verify current_streak increments correctly.

4

Build the heatmap data endpoint and React frontend

The heatmap endpoint returns 90 days of completion counts grouped by date. The frontend renders this as a grid of colored squares — darker color means more completions. This is the most satisfying visual feature.

prompt.txt
1// Prompt to type into Replit Agent:
2// Add these routes to server/routes/habits.js:
3//
4// GET /api/habits/:id/history — heatmap data for last 90 days
5// Generate a series of 90 dates (today going back) and LEFT JOIN with completions
6// Return array of {date: 'YYYY-MM-DD', count: number} for all 90 days
7// Use PostgreSQL: SELECT d.date, COALESCE(c.count, 0) AS count
8// FROM generate_series(CURRENT_DATE - 89, CURRENT_DATE, '1 day') AS d(date)
9// LEFT JOIN completions c ON c.completed_date = d.date AND c.habit_id = :id
10// ORDER BY d.date ASC
11//
12// GET /api/habits/:id/stats — detailed stats
13// Return: current_streak, longest_streak, total_completions, last_completed,
14// completion_rate_30d (completions in last 30 days / 30 * 100),
15// completion_rate_90d (same for 90 days)
16//
17// GET /api/dashboard — summary for homepage
18// Return: total_habits (non-archived), completed_today (habits with completion today),
19// longest_active_streak (max current_streak across all user's habits)
20//
21// React frontend components:
22// 1. HabitCard: shows habit name, colored circle complete button (filled when done today),
23// streak fire icon with number, target_count progress (e.g. '1/1 today')
24// Animate the complete button: scale-up + color fill on click
25// 2. HeatmapGrid: 90 squares (10 rows x 9 cols or 13 weeks x 7 days)
26// Color intensity based on count: 0=gray, 1=light, 2+=dark (using habit.color)
27// Tooltip on hover showing date and count
28// 3. Dashboard: greeting, summary stats cards, list of all HabitCards

Pro tip: PostgreSQL's generate_series function is perfect for heatmaps — it generates a row for every date in the range even if no completions exist for that date, making the LEFT JOIN return 0 for empty days.

Expected result: GET /api/habits/1/history returns an array of 90 objects like [{date: '2026-01-01', count: 0}, {date: '2026-01-02', count: 1}]. The frontend renders these as a color-graded grid.

5

Deploy on Autoscale

Habit trackers need almost no infrastructure. Deploy on Autoscale for zero cost when idle. Add a simple notification reminder using browser push notifications as an enhancement — no server-side infrastructure needed.

prompt.txt
1// Prompt to type into Replit Agent:
2// Finalize the app for deployment:
3// 1. Add SESSION_SECRET to Replit Secrets (lock icon in sidebar)
4// Value: any 32-character random string
5// 2. Ensure server/index.js binds to 0.0.0.0:
6// app.listen(process.env.PORT || 3000, '0.0.0.0', ...)
7// 3. Add a PostgreSQL retry wrapper to server/db.js:
8// const pool = new Pool({ connectionString: process.env.DATABASE_URL,
9// connectionTimeoutMillis: 5000, idleTimeoutMillis: 30000 })
10// 4. Add a POST /api/habits route for creating habits:
11// Body: {name, description, color, frequency, targetCount, reminderTime}
12// INSERT into habits with user_id = req.user.id
13// Also create an empty streak_cache row: INSERT INTO streak_cache (habit_id) VALUES (:id) ON CONFLICT DO NOTHING
14// 5. Add PATCH /api/habits/:id/archive for soft-archiving a habit
15// 6. Deploy → Autoscale (button in top-right Deploy menu)

Pro tip: After deploying, share the URL with yourself on mobile. The completion button should work well on a phone screen. Consider adding a PWA manifest so users can add it to their home screen for a native-app feel.

Expected result: The app is live at your Replit deployment URL. Creating a habit, completing it for several days, and viewing the heatmap shows colored squares growing darker with each day's completion.

Complete code

server/routes/habits.js
1const { Router } = require('express');
2const { db } = require('../db');
3const { habits, completions, streakCache } = require('../../shared/schema');
4const { eq, and, sql } = require('drizzle-orm');
5
6const router = Router();
7
8router.get('/api/habits', async (req, res) => {
9 if (!req.user) return res.status(401).json({ error: 'Auth required' });
10 const today = new Date().toISOString().split('T')[0];
11 const result = await db.execute(
12 sql`SELECT h.id, h.name, h.description, h.color, h.frequency, h.target_count,
13 sc.current_streak, sc.longest_streak, sc.total_completions,
14 CASE WHEN c.habit_id IS NOT NULL THEN true ELSE false END AS completed_today
15 FROM habits h
16 LEFT JOIN streak_cache sc ON sc.habit_id = h.id
17 LEFT JOIN completions c ON c.habit_id = h.id AND c.completed_date = ${today}
18 WHERE h.user_id = ${req.user.id} AND h.is_archived = false
19 ORDER BY h.created_at ASC`
20 );
21 res.json(result.rows);
22});
23
24router.post('/api/habits', async (req, res) => {
25 if (!req.user) return res.status(401).json({ error: 'Auth required' });
26 const { name, description, color, frequency, targetCount, reminderTime } = req.body;
27 const [habit] = await db.insert(habits).values({
28 userId: req.user.id, name, description, color: color || '#3B82F6',
29 frequency: frequency || 'daily', targetCount: targetCount || 1,
30 reminderTime: reminderTime || null
31 }).returning();
32 await db.insert(streakCache).values({ habitId: habit.id })
33 .onConflictDoNothing();
34 res.json(habit);
35});
36
37router.post('/api/habits/:id/complete', async (req, res) => {
38 if (!req.user) return res.status(401).json({ error: 'Auth required' });
39 const habitId = Number(req.params.id);
40 const dateStr = req.body.date || new Date().toISOString().split('T')[0];
41 const habit = await db.query.habits.findFirst({
42 where: and(eq(habits.id, habitId), eq(habits.userId, req.user.id))
43 });
44 if (!habit) return res.status(404).json({ error: 'Habit not found' });
45 const existing = await db.query.completions.findFirst({
46 where: and(eq(completions.habitId, habitId), eq(completions.completedDate, dateStr))
47 });
48 const completed = !existing;
49 if (existing) {
50 await db.delete(completions).where(eq(completions.id, existing.id));
51 } else {
52 await db.insert(completions).values({ habitId, completedDate: dateStr });
53 }
54 await db.execute(sql`SELECT calculate_streak(${habitId})`);
55 const [cache] = await db.select().from(streakCache).where(eq(streakCache.habitId, habitId));
56 res.json({ completed, currentStreak: cache?.currentStreak || 0, longestStreak: cache?.longestStreak || 0 });
57});
58
59router.get('/api/habits/:id/history', async (req, res) => {
60 if (!req.user) return res.status(401).json({ error: 'Auth required' });

Customization ideas

Streak milestone celebrations

After each completion, check if the new current_streak is a milestone (7, 30, 100 days). If so, return a milestone flag in the completion response. The frontend triggers a confetti animation (using the canvas-confetti npm package) when a milestone is hit.

Habit stacking

Add a stack_after_habit_id column to habits. When completing a habit, the UI automatically focuses the next habit in the stack. This implements the 'habit stacking' productivity technique where new habits attach to existing ones.

Public habit profile

Add a is_public column to habits. A public heatmap page at /profile/:userId shows all public habits and their heatmaps, no login required. Share your progress page with friends for accountability.

Common pitfalls

Pitfall: Recalculating streaks by summing completions on every dashboard load

How to avoid: Use the streak_cache table as shown. Recalculate only when a completion is toggled. Dashboard loads read the cached values — sub-millisecond regardless of history size.

Pitfall: Not handling the case where today's completion doesn't exist but yesterday's does

How to avoid: The streak function checks from today backward first. If today has no completion, it then checks from yesterday backward. This way the streak stays alive until a full day is missed.

Pitfall: Using JavaScript Date() arithmetic for streak calculations instead of PostgreSQL

How to avoid: Run streak calculations in PostgreSQL using CURRENT_DATE (server's local date) and INTERVAL '1 day' arithmetic. All date comparisons happen in the database timezone consistently.

Best practices

  • Use the streak_cache table to store pre-calculated streak data. Recalculate on every completion toggle, not on every read. This makes dashboard loads instant regardless of history size.
  • Store reminder times as 'HH:MM' strings, not full timestamps. This makes it easy to check 'did 15:30 already pass today?' without timezone arithmetic.
  • Use Replit Auth for user isolation — every habits and completions query must include WHERE user_id = req.user.id. One user should never see another's habits.
  • Use Drizzle Studio (database icon in sidebar) to test the calculate_streak PostgreSQL function directly with SELECT calculate_streak(1) before connecting it to the API.
  • Handle the unique constraint on completions (habit_id, completed_date) gracefully — if two rapid taps fire simultaneously, the second insert fails silently with ON CONFLICT DO NOTHING.
  • Deploy on Autoscale — habit trackers have brief daily check-in sessions. The free tier is sufficient for personal use.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a habit tracker with PostgreSQL and Node.js. I have a completions table with columns habit_id and completed_date (date type). I need a PostgreSQL PL/pgSQL function calculate_streak(p_habit_id INTEGER) that: (1) calculates the current streak as consecutive completed dates from today backward (if today is not completed, start from yesterday), (2) calculates the longest streak ever as consecutive dates in the full history, (3) upserts these values into a streak_cache table (habit_id, current_streak, longest_streak, total_completions, last_completed). Help me write the complete function.

Build Prompt

Add a weekly check-in email to the habit tracker. Create scripts/weeklyDigest.js that runs every Sunday via Scheduled Deployment. It queries each user's habits and streak_cache, calculates overall completion rate for the past week, and sends a personalized summary email via SendGrid: 'You completed X of Y habits this week. Longest streak: Z days.' Store SENDGRID_API_KEY in Replit Secrets.

Frequently asked questions

How do I mark a habit as done for a past date?

The POST /api/habits/:id/complete endpoint accepts a date parameter in the body (format: YYYY-MM-DD). Send {date: '2026-04-20'} to toggle the completion for April 20. The streak recalculates from the new completion history.

What does 'weekdays' frequency mean?

A habit with frequency='weekdays' is expected every Monday through Friday. The streak logic skips Saturdays and Sundays — not completing a weekday habit on the weekend doesn't break the streak. The current streak function should check only weekday dates when frequency='weekdays'.

Can multiple people share a habit tracker?

In the current design, each Replit Auth user has their own habits. For shared accountability, you could add a feature where a habit's heatmap page has a public share URL (/share/:habitId) showing the completion grid without login.

How do I add push notification reminders?

Browser push notifications use the Web Push API. The frontend requests notification permission, and the service worker receives push events. The server uses the web-push npm package to send notifications. Store the VAPID keys in Replit Secrets. This doesn't require any server-side scheduled infrastructure — it's purely browser-based.

Do I need a paid Replit plan?

No. The free plan is sufficient for a personal habit tracker. The built-in PostgreSQL, Autoscale deployment, and Replit Auth are all included at no cost. The only limitation is the database sleeping after 5 minutes of inactivity, adding a brief delay on the first daily login.

What happens to the streak if I miss a day?

The streak resets to zero. The calculate_streak function counts consecutive days backward from today or yesterday — a single missed day breaks the chain. The longest_streak in streak_cache preserves your all-time best so the missed day doesn't erase your achievement entirely.

Can RapidDev build a custom wellness or accountability app?

Yes. RapidDev has built 600+ apps and can add features like team accountability groups, progress photos, coach dashboards, and integration with fitness APIs. Book a free consultation at rapidevelopers.com.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.