# How to Build a Habit Tracker with Replit

- Tool: How to Build with Replit
- Difficulty: Beginner
- Compatibility: Replit Free
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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 to type into Replit Agent:
// Build a habit tracker app with Express and PostgreSQL using Drizzle ORM.
// Create these tables in shared/schema.ts:
// - habits: id serial pk, user_id text not null, name text not null,
//   description text, color text not null default '#3B82F6',
//   frequency text default 'daily' (daily/weekdays/weekends/weekly),
//   target_count integer default 1,
//   reminder_time text (format 'HH:MM', nullable),
//   is_archived boolean default false, created_at timestamp
// - completions: id serial pk, habit_id integer references habits not null,
//   completed_date date not null, count integer default 1,
//   notes text, created_at timestamp,
//   UNIQUE constraint on (habit_id, completed_date)
// - streak_cache: id serial pk, habit_id integer references habits not null unique,
//   current_streak integer default 0, longest_streak integer default 0,
//   last_completed date, total_completions integer default 0,
//   updated_at timestamp
// Create a PostgreSQL function calculate_streak(p_habit_id integer)
// that computes current_streak from completions table and upserts into streak_cache.
// 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.

```
const { db } = require('../db');
const { habits, completions, streakCache } = require('../../shared/schema');
const { eq, and, sql } = require('drizzle-orm');

router.get('/api/habits', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });

  const today = new Date().toISOString().split('T')[0];

  const result = await db.execute(
    sql`SELECT h.id, h.name, h.description, h.color, h.frequency, h.target_count, h.reminder_time,
              sc.current_streak, sc.longest_streak, sc.total_completions, sc.last_completed,
              CASE WHEN c.habit_id IS NOT NULL THEN true ELSE false END AS completed_today
        FROM habits h
        LEFT JOIN streak_cache sc ON sc.habit_id = h.id
        LEFT JOIN completions c ON c.habit_id = h.id AND c.completed_date = ${today}
        WHERE h.user_id = ${req.user.id} AND h.is_archived = false
        ORDER BY h.created_at ASC`
  );

  res.json(result.rows);
});

router.post('/api/habits/:id/complete', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });

  const habitId = Number(req.params.id);
  const dateStr = req.body.date || new Date().toISOString().split('T')[0];

  // Verify ownership
  const habit = await db.query.habits.findFirst({
    where: and(eq(habits.id, habitId), eq(habits.userId, req.user.id))
  });
  if (!habit) return res.status(404).json({ error: 'Habit not found' });

  const existing = await db.query.completions.findFirst({
    where: and(eq(completions.habitId, habitId), eq(completions.completedDate, dateStr))
  });

  let completed;
  if (existing) {
    await db.delete(completions).where(eq(completions.id, existing.id));
    completed = false;
  } else {
    await db.insert(completions).values({
      habitId, completedDate: dateStr, notes: req.body.notes || null
    });
    completed = true;
  }

  // Recalculate streak and update cache
  await db.execute(sql`SELECT calculate_streak(${habitId})`);

  const [cache] = await db.select().from(streakCache).where(eq(streakCache.habitId, habitId));

  res.json({ completed, currentStreak: cache?.currentStreak || 0, longestStreak: cache?.longestStreak || 0 });
});
```

> 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 to type into Replit Agent:
// Add this PostgreSQL function to the database migration.
// Create or replace the calculate_streak function:
//
// CREATE OR REPLACE FUNCTION calculate_streak(p_habit_id INTEGER)
// RETURNS VOID AS $$
// DECLARE
//   v_current_streak INTEGER := 0;
//   v_longest_streak INTEGER := 0;
//   v_temp_streak INTEGER := 0;
//   v_last_date DATE := NULL;
//   v_check_date DATE;
//   v_total INTEGER;
//   v_row RECORD;
// BEGIN
//   -- Count total completions
//   SELECT COUNT(*) INTO v_total FROM completions WHERE habit_id = p_habit_id;
//
//   -- Calculate current streak (consecutive days ending today or yesterday)
//   v_check_date := CURRENT_DATE;
//   LOOP
//     IF EXISTS (SELECT 1 FROM completions WHERE habit_id = p_habit_id AND completed_date = v_check_date) THEN
//       v_current_streak := v_current_streak + 1;
//       v_check_date := v_check_date - INTERVAL '1 day';
//     ELSE
//       EXIT;
//     END IF;
//   END LOOP;
//
//   -- If today not completed, check if streak continues from yesterday
//   IF v_current_streak = 0 THEN
//     v_check_date := CURRENT_DATE - INTERVAL '1 day';
//     LOOP
//       IF EXISTS (SELECT 1 FROM completions WHERE habit_id = p_habit_id AND completed_date = v_check_date) THEN
//         v_current_streak := v_current_streak + 1;
//         v_check_date := v_check_date - INTERVAL '1 day';
//       ELSE
//         EXIT;
//       END IF;
//     END LOOP;
//   END IF;
//
//   -- Calculate longest streak from all completions
//   v_temp_streak := 0;
//   v_last_date := NULL;
//   FOR v_row IN SELECT completed_date FROM completions WHERE habit_id = p_habit_id ORDER BY completed_date ASC
//   LOOP
//     IF v_last_date IS NULL OR v_row.completed_date = v_last_date + INTERVAL '1 day' THEN
//       v_temp_streak := v_temp_streak + 1;
//       v_longest_streak := GREATEST(v_longest_streak, v_temp_streak);
//     ELSE
//       v_temp_streak := 1;
//     END IF;
//     v_last_date := v_row.completed_date;
//   END LOOP;
//
//   -- Upsert streak_cache
//   INSERT INTO streak_cache (habit_id, current_streak, longest_streak, total_completions, last_completed, updated_at)
//   VALUES (p_habit_id, v_current_streak, v_longest_streak, v_total,
//           (SELECT MAX(completed_date) FROM completions WHERE habit_id = p_habit_id), NOW())
//   ON CONFLICT (habit_id) DO UPDATE SET
//     current_streak = EXCLUDED.current_streak,
//     longest_streak = EXCLUDED.longest_streak,
//     total_completions = EXCLUDED.total_completions,
//     last_completed = EXCLUDED.last_completed,
//     updated_at = NOW();
// END;
// $$ LANGUAGE plpgsql;
//
// 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 to type into Replit Agent:
// Add these routes to server/routes/habits.js:
//
// GET /api/habits/:id/history — heatmap data for last 90 days
//   Generate a series of 90 dates (today going back) and LEFT JOIN with completions
//   Return array of {date: 'YYYY-MM-DD', count: number} for all 90 days
//   Use PostgreSQL: SELECT d.date, COALESCE(c.count, 0) AS count
//     FROM generate_series(CURRENT_DATE - 89, CURRENT_DATE, '1 day') AS d(date)
//     LEFT JOIN completions c ON c.completed_date = d.date AND c.habit_id = :id
//     ORDER BY d.date ASC
//
// GET /api/habits/:id/stats — detailed stats
//   Return: current_streak, longest_streak, total_completions, last_completed,
//   completion_rate_30d (completions in last 30 days / 30 * 100),
//   completion_rate_90d (same for 90 days)
//
// GET /api/dashboard — summary for homepage
//   Return: total_habits (non-archived), completed_today (habits with completion today),
//   longest_active_streak (max current_streak across all user's habits)
//
// React frontend components:
// 1. HabitCard: shows habit name, colored circle complete button (filled when done today),
//    streak fire icon with number, target_count progress (e.g. '1/1 today')
//    Animate the complete button: scale-up + color fill on click
// 2. HeatmapGrid: 90 squares (10 rows x 9 cols or 13 weeks x 7 days)
//    Color intensity based on count: 0=gray, 1=light, 2+=dark (using habit.color)
//    Tooltip on hover showing date and count
// 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 to type into Replit Agent:
// Finalize the app for deployment:
// 1. Add SESSION_SECRET to Replit Secrets (lock icon in sidebar)
//    Value: any 32-character random string
// 2. Ensure server/index.js binds to 0.0.0.0:
//    app.listen(process.env.PORT || 3000, '0.0.0.0', ...)
// 3. Add a PostgreSQL retry wrapper to server/db.js:
//    const pool = new Pool({ connectionString: process.env.DATABASE_URL,
//      connectionTimeoutMillis: 5000, idleTimeoutMillis: 30000 })
// 4. Add a POST /api/habits route for creating habits:
//    Body: {name, description, color, frequency, targetCount, reminderTime}
//    INSERT into habits with user_id = req.user.id
//    Also create an empty streak_cache row: INSERT INTO streak_cache (habit_id) VALUES (:id) ON CONFLICT DO NOTHING
// 5. Add PATCH /api/habits/:id/archive for soft-archiving a habit
// 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 example

File: `server/routes/habits.js`

```javascript
const { Router } = require('express');
const { db } = require('../db');
const { habits, completions, streakCache } = require('../../shared/schema');
const { eq, and, sql } = require('drizzle-orm');

const router = Router();

router.get('/api/habits', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });
  const today = new Date().toISOString().split('T')[0];
  const result = await db.execute(
    sql`SELECT h.id, h.name, h.description, h.color, h.frequency, h.target_count,
              sc.current_streak, sc.longest_streak, sc.total_completions,
              CASE WHEN c.habit_id IS NOT NULL THEN true ELSE false END AS completed_today
        FROM habits h
        LEFT JOIN streak_cache sc ON sc.habit_id = h.id
        LEFT JOIN completions c ON c.habit_id = h.id AND c.completed_date = ${today}
        WHERE h.user_id = ${req.user.id} AND h.is_archived = false
        ORDER BY h.created_at ASC`
  );
  res.json(result.rows);
});

router.post('/api/habits', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });
  const { name, description, color, frequency, targetCount, reminderTime } = req.body;
  const [habit] = await db.insert(habits).values({
    userId: req.user.id, name, description, color: color || '#3B82F6',
    frequency: frequency || 'daily', targetCount: targetCount || 1,
    reminderTime: reminderTime || null
  }).returning();
  await db.insert(streakCache).values({ habitId: habit.id })
    .onConflictDoNothing();
  res.json(habit);
});

router.post('/api/habits/:id/complete', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });
  const habitId = Number(req.params.id);
  const dateStr = req.body.date || new Date().toISOString().split('T')[0];
  const habit = await db.query.habits.findFirst({
    where: and(eq(habits.id, habitId), eq(habits.userId, req.user.id))
  });
  if (!habit) return res.status(404).json({ error: 'Habit not found' });
  const existing = await db.query.completions.findFirst({
    where: and(eq(completions.habitId, habitId), eq(completions.completedDate, dateStr))
  });
  const completed = !existing;
  if (existing) {
    await db.delete(completions).where(eq(completions.id, existing.id));
  } else {
    await db.insert(completions).values({ habitId, completedDate: dateStr });
  }
  await db.execute(sql`SELECT calculate_streak(${habitId})`);
  const [cache] = await db.select().from(streakCache).where(eq(streakCache.habitId, habitId));
  res.json({ completed, currentStreak: cache?.currentStreak || 0, longestStreak: cache?.longestStreak || 0 });
});

router.get('/api/habits/:id/history', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });
```

## Common mistakes

- **Recalculating streaks by summing completions on every dashboard load** — Counting consecutive dates in a query is slow and gets slower as completion history grows. A user with 2 years of data forces a scan of 730+ rows on every page load. Fix: 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.
- **Not handling the case where today's completion doesn't exist but yesterday's does** — The current streak should show as 5 even if today hasn't been completed yet, as long as yesterday and the 4 days before it were completed. Requiring today's completion to show a non-zero streak frustrates users who check their stats in the morning. Fix: 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.
- **Using JavaScript Date() arithmetic for streak calculations instead of PostgreSQL** — JavaScript Date math has timezone issues — midnight in the user's timezone might be yesterday or tomorrow in UTC. Streak calculations done client-side or in Node.js can shift by one day. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/habit-tracker
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/habit-tracker
