# How to Build a Productivity App with Replit

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

## TL;DR

Build a personal productivity app in Replit with notes, tasks, bookmarks, and a daily planner — a Notion-lite — in 1-2 hours. Replit Agent scaffolds the Express backend with PostgreSQL, Replit Auth handles login, and each user gets a fully isolated workspace. No external API keys needed.

## Before you start

- A Replit account (Free tier is sufficient)
- No external API keys needed — Replit Auth and PostgreSQL are built in
- Basic familiarity with what you want to track (notes, tasks, bookmarks) to customize the schema

## Step-by-step guide

### 1. Scaffold the project with Replit Agent

Create a new Replit App and paste this prompt. Agent builds the complete productivity backend with all five tables and a React frontend with the three-panel layout.

```
// Build a personal productivity app with Express and PostgreSQL using Drizzle ORM.
// Use Replit Auth for user authentication — every query must scope data by user_id.
//
// Tables:
// 1. folders: id serial primary key, user_id text not null, name text not null,
//    parent_id integer references folders (self-referential nesting),
//    color text, position integer default 0
// 2. notes: id serial primary key, user_id text not null, title text not null,
//    content text (markdown body), folder_id integer references folders,
//    is_pinned boolean default false, created_at timestamp default now(),
//    updated_at timestamp default now()
// 3. tasks: id serial primary key, user_id text not null, title text not null,
//    description text, due_date timestamp, priority text default 'medium'
//    (enum: low/medium/high/urgent), status text default 'todo'
//    (enum: todo/in_progress/done), recurrence text default 'none'
//    (enum: none/daily/weekly/monthly), completed_at timestamp, created_at timestamp default now()
// 4. bookmarks: id serial primary key, user_id text not null, url text not null,
//    title text, description text, tags text[] (PostgreSQL text array),
//    favicon_url text, created_at timestamp default now()
// 5. daily_plans: id serial primary key, user_id text not null, date date not null,
//    task_ids jsonb (ordered array of task IDs), notes text,
//    unique(user_id, date)
//
// Routes:
// GET/POST /api/notes, PUT/DELETE /api/notes/:id
// GET/POST /api/folders (GET returns tree structure via recursive CTE)
// GET/POST /api/tasks, PATCH /api/tasks/:id/status, PUT /api/tasks/:id
// GET/POST /api/bookmarks, DELETE /api/bookmarks/:id
// GET/PUT /api/daily/:date (get or create daily plan for a date)
//
// React frontend: three-panel layout with folder tree sidebar,
// center content list, right detail/editor panel.
// Bind server to 0.0.0.0:3000.
```

> Pro tip: After Agent scaffolds the app, open the Database tab (Drizzle Studio) and insert a few test notes and tasks to verify the schema before building the frontend. It's much easier to debug schema issues before the UI is wired up.

**Expected result:** Running Express app with all five tables created. Replit Auth login page appears on first visit.

### 2. Build the folder tree with recursive CTE

The nested folder structure is the most technically complex part. This route uses a PostgreSQL recursive CTE to fetch the entire folder tree in a single query instead of multiple round-trips.

```
const express = require('express');
const { db } = require('../db');
const { folders } = require('../schema');
const { eq, isNull } = require('drizzle-orm');

const router = express.Router();

// Get entire folder tree for the current user using recursive CTE
router.get('/api/folders', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });

  // Recursive CTE: start with root folders, then recursively include children
  const result = await db.execute({
    sql: `
      WITH RECURSIVE tree AS (
        SELECT id, name, parent_id, color, position, 0 AS depth
        FROM folders
        WHERE user_id = $1 AND parent_id IS NULL
        UNION ALL
        SELECT f.id, f.name, f.parent_id, f.color, f.position, t.depth + 1
        FROM folders f
        JOIN tree t ON f.parent_id = t.id
        WHERE f.user_id = $1
      )
      SELECT * FROM tree ORDER BY depth, position, name
    `,
    params: [req.user.id],
  });

  // Build nested tree structure from flat rows
  const folderMap = {};
  const roots = [];

  for (const row of result.rows) {
    folderMap[row.id] = { ...row, children: [] };
  }
  for (const row of result.rows) {
    if (row.parent_id) {
      folderMap[row.parent_id]?.children.push(folderMap[row.id]);
    } else {
      roots.push(folderMap[row.id]);
    }
  }

  return res.json({ folders: roots });
});

router.post('/api/folders', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { name, parentId, color } = req.body;
  if (!name) return res.status(400).json({ error: 'name is required' });

  const row = await db.insert(folders)
    .values({ userId: req.user.id, name, parentId: parentId || null, color: color || null })
    .returning();
  return res.status(201).json(row[0]);
});

module.exports = router;
```

> Pro tip: The self-referential folders table can create infinite loops if parent_id somehow points to a descendant. Prevent this in the create/update routes by checking that the new parent_id is not already a child of the folder being moved.

**Expected result:** GET /api/folders returns a nested tree: [{id:1, name:'Work', children:[{id:2, name:'Projects', children:[]}]}]. Root folders have no parent_id.

### 3. Add the tasks API with priority and status filtering

Tasks are the most-used feature. This route supports filtering by status, priority, and due date — essential for showing 'what's overdue today' and 'what's urgent'.

```
const express = require('express');
const { db } = require('../db');
const { tasks } = require('../schema');
const { eq, and, lte, gte, inArray } = require('drizzle-orm');

const router = express.Router();

router.get('/api/tasks', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { status, priority, dueBefore, dueAfter } = req.query;

  const conditions = [eq(tasks.userId, req.user.id)];
  if (status) conditions.push(eq(tasks.status, status));
  if (priority) conditions.push(eq(tasks.priority, priority));
  if (dueBefore) conditions.push(lte(tasks.dueDate, new Date(dueBefore)));
  if (dueAfter) conditions.push(gte(tasks.dueDate, new Date(dueAfter)));

  const rows = await db.select().from(tasks)
    .where(and(...conditions))
    .orderBy(tasks.dueDate, tasks.priority);

  return res.json({ tasks: rows });
});

router.post('/api/tasks', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { title, description, dueDate, priority, recurrence } = req.body;
  if (!title) return res.status(400).json({ error: 'title is required' });

  const row = await db.insert(tasks).values({
    userId: req.user.id,
    title,
    description: description || null,
    dueDate: dueDate ? new Date(dueDate) : null,
    priority: priority || 'medium',
    recurrence: recurrence || 'none',
  }).returning();
  return res.status(201).json(row[0]);
});

router.patch('/api/tasks/:id/status', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { status } = req.body;
  const validStatuses = ['todo', 'in_progress', 'done'];
  if (!validStatuses.includes(status)) {
    return res.status(400).json({ error: 'Invalid status' });
  }
  const updated = await db.update(tasks)
    .set({ status, completedAt: status === 'done' ? new Date() : null, updatedAt: new Date() })
    .where(and(eq(tasks.id, parseInt(req.params.id)), eq(tasks.userId, req.user.id)))
    .returning();
  if (!updated[0]) return res.status(404).json({ error: 'Not found' });
  return res.json(updated[0]);
});

module.exports = router;
```

> Pro tip: Add a GET /api/tasks?status=todo&dueBefore=2024-12-31 endpoint call on the dashboard 'Today' view to show overdue tasks — tasks where due_date is in the past and status is not 'done'. This is the most useful view for daily planning.

**Expected result:** GET /api/tasks returns user's tasks. PATCH /api/tasks/1/status with {status:'done'} marks it complete and sets completed_at.

### 4. Add bookmarks with auto-fetch and the daily planner

Bookmarks auto-fetch page titles server-side (avoiding CORS issues), and the daily planner stores an ordered list of task IDs for each date.

```
// Ask Agent to add bookmarks and daily planner with this prompt:
// Add to the Express app:
//
// 1. POST /api/bookmarks:
//    - Require Replit Auth
//    - Accept {url, tags} in request body
//    - Server-side fetch the URL using node-fetch: GET the HTML, extract <title> tag
//    - Try to find favicon at /favicon.ico on the domain
//    - Insert into bookmarks with fetched title, favicon_url, and tags
//    - If fetch fails (CORS, timeout), store with title=null and continue
//    - Set a 5-second timeout on the title fetch to not block the response
//
// 2. GET /api/bookmarks:
//    - Require auth
//    - Accept optional ?tag= query param to filter by tag
//    - For tag filter, use PostgreSQL array contains: WHERE $1 = ANY(tags)
//    - Return bookmarks ordered by created_at desc
//
// 3. GET /api/daily/:date:
//    - Require auth
//    - Accept date in YYYY-MM-DD format
//    - If daily_plan exists for user+date, return it with task details joined
//    - If not, INSERT a new empty daily_plan and return it
//    - Join task_ids array against tasks table to return full task objects in order
//
// 4. PUT /api/daily/:date:
//    - Require auth
//    - Accept {task_ids: [array of IDs], notes: string}
//    - Upsert: INSERT ... ON CONFLICT (user_id, date) DO UPDATE
//    - Return the updated plan
```

> Pro tip: The server-side title fetch in POST /api/bookmarks is important — doing it client-side would hit CORS errors on most websites. In the Express handler, use Promise.race([fetchTitle(), timeoutAfter5Seconds()]) so a slow website doesn't block the entire bookmark save.

**Expected result:** POST /api/bookmarks with {url: 'https://example.com', tags: ['reading']} returns a bookmark with title auto-populated. GET /api/daily/2024-12-25 returns an empty plan or existing plan with full task objects.

### 5. Build the React three-panel layout

Ask Agent to create the React frontend with the signature three-panel layout: folder sidebar, content list, and editor/detail panel.

```
// Ask Agent to build the React frontend with this prompt:
// Build a React productivity app with a three-panel layout:
//
// Left sidebar (240px fixed):
// - Section navigation icons at top: Notes, Tasks, Bookmarks, Today (icons with labels)
// - Below icons: folder tree rendered from GET /api/folders
//   Each folder is a collapsible row with indentation based on depth
//   An 'Add folder' button creates a subfolder via POST /api/folders
//
// Center panel (flex-grow):
// - Notes view: list of notes for selected folder, sorted by is_pinned desc, updated_at desc
//   Each row shows title, first line of content, and timestamp
//   Click row to open in right panel
// - Tasks view: list with checkbox, title, priority dot (urgent=red, high=orange, medium=blue, low=gray),
//   due date badge (red if overdue, orange if today). Click checkbox calls PATCH /api/tasks/:id/status
// - Bookmarks view: list with favicon, title, domain, and tag chips
// - Today view: date header with today's date, ordered list of today's tasks from GET /api/daily/:date
//
// Right panel (320px fixed):
// - Notes: textarea with live markdown preview toggle (toggle between edit and preview modes)
//   Auto-save on 2-second debounce via PUT /api/notes/:id
// - Tasks: edit form with all fields
// - Bookmarks: full URL, description, tag editor
//
// Use React Router. Add keyboard shortcut Cmd+N to create a new note.
```

**Expected result:** The app shows the three-panel layout after login. Notes appear in the center panel, markdown renders in the right panel. Clicking a task checkbox toggles it complete.

## Complete code example

File: `server/routes/tasks.js`

```javascript
const express = require('express');
const { db } = require('../db');
const { tasks } = require('../schema');
const { eq, and, lte, gte, desc } = require('drizzle-orm');
const { withDbRetry } = require('../lib/retryDb');

const router = express.Router();

const VALID_STATUSES = ['todo', 'in_progress', 'done'];
const VALID_PRIORITIES = ['low', 'medium', 'high', 'urgent'];

router.get('/api/tasks', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { status, priority, dueBefore, dueAfter } = req.query;
  const conditions = [eq(tasks.userId, req.user.id)];
  if (status && VALID_STATUSES.includes(status)) conditions.push(eq(tasks.status, status));
  if (priority && VALID_PRIORITIES.includes(priority)) conditions.push(eq(tasks.priority, priority));
  if (dueBefore) conditions.push(lte(tasks.dueDate, new Date(dueBefore)));
  if (dueAfter) conditions.push(gte(tasks.dueDate, new Date(dueAfter)));
  const rows = await db.select().from(tasks)
    .where(and(...conditions))
    .orderBy(desc(tasks.priority), tasks.dueDate);
  return res.json({ tasks: rows });
});

router.post('/api/tasks', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { title, description, dueDate, priority, recurrence } = req.body;
  if (!title?.trim()) return res.status(400).json({ error: 'title is required' });
  const row = await withDbRetry(() =>
    db.insert(tasks).values({
      userId: req.user.id,
      title: title.trim(),
      description: description || null,
      dueDate: dueDate ? new Date(dueDate) : null,
      priority: VALID_PRIORITIES.includes(priority) ? priority : 'medium',
      recurrence: recurrence || 'none',
    }).returning()
  );
  return res.status(201).json(row[0]);
});

router.put('/api/tasks/:id', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { title, description, dueDate, priority, status } = req.body;
  const updated = await db.update(tasks)
    .set({
      ...(title && { title }),
      ...(description !== undefined && { description }),
      ...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),
      ...(priority && { priority }),
      ...(status && { status }),
      updatedAt: new Date(),
    })
    .where(and(eq(tasks.id, parseInt(req.params.id)), eq(tasks.userId, req.user.id)))
    .returning();
  if (!updated[0]) return res.status(404).json({ error: 'Not found' });
  return res.json(updated[0]);
});

module.exports = router;
```

## Common mistakes

- **Not scoping every query by user_id** — Without user_id filtering, any logged-in user can read or delete any other user's notes, tasks, or bookmarks — a serious data breach. Fix: Every SELECT, UPDATE, and DELETE must include WHERE user_id = req.user.id. Add a middleware that returns 401 if req.user is not set, applied to all productivity routes.
- **Fetching bookmark titles client-side with the browser's fetch()** — Most websites block cross-origin requests (CORS). The browser will get a CORS error on almost every URL the user wants to bookmark. Fix: Always fetch the page title server-side in the Express POST /api/bookmarks handler using node-fetch. Wrap it in a 5-second timeout so slow or broken sites don't block the response.
- **Loading all notes content for the list view** — Notes with thousands of words of markdown cause slow list renders when fetching all content for all notes in a folder. Fix: In GET /api/notes, SELECT only id, title, is_pinned, updated_at and the first 150 characters of content for the preview. Load the full content only when a specific note is opened.
- **Infinite recursion in the folder tree CTE** — If a parent_id accidentally points to a descendant folder, the WITH RECURSIVE query runs forever and crashes the database connection. Fix: Add a CYCLE DETECTION clause to the CTE: WITH RECURSIVE tree AS (...) CYCLE id SET is_cycle USING path. This automatically stops infinite loops.

## Best practices

- Scope every database query by user_id — Replit Auth provides req.user.id, use it on every route as the first filter.
- Use PostgreSQL's WITH RECURSIVE CTE for the folder tree instead of making N database calls to fetch nested levels — one query is always faster.
- Auto-save notes with a 2-second debounce in React — users expect changes to persist without clicking a save button.
- Use Drizzle Studio (Database tab in Replit sidebar) to quickly inspect and edit your data during development without writing SQL queries.
- Snapshot the unit_price (for bookmarks) or content at time of creation — this prevents corrupted data if the source URL changes later.
- Add a daily_plans upsert with ON CONFLICT DO UPDATE — the user should be able to freely update their day plan without creating duplicate rows.
- Deploy on Autoscale — personal productivity apps have irregular usage patterns with long idle periods between bursts of note-taking activity.

## Frequently asked questions

### Can multiple users share the same productivity workspace?

The default build creates fully isolated personal workspaces — no user sees another's data. To add collaboration, you'd extend the schema with a shared_notes table or an org_id column on the folders table. That's covered in the team-workspace build guide.

### What Replit plan do I need?

Free tier is sufficient. Replit Auth is built-in and free, the PostgreSQL database is free with 10GB storage (plenty for text-heavy productivity data), and Autoscale deployment is included. No external API keys are needed for the core build.

### How do I handle markdown rendering in the notes editor?

Ask Agent to add a markdown preview toggle using a library like marked or markdown-it (npm packages). The editor shows raw markdown in a textarea, and a Preview button renders the HTML using marked(content). Both panels can be shown side-by-side in wide screen layouts.

### Will notes auto-save if I close the browser mid-edit?

With a 2-second debounce auto-save, any changes you've paused typing on will have saved. To protect against browser crashes mid-keystroke, save a draft copy in localStorage as a backup — on load, compare localStorage draft against the server version and prompt the user to restore if the draft is newer.

### How deep can I nest folders?

The recursive CTE has no hard depth limit. Practically, deeply nested trees become hard to navigate in a UI. Consider limiting nesting to 3-4 levels in the frontend by disabling the 'Create subfolder' option beyond a certain depth.

### Should I deploy on Autoscale or Reserved VM?

Autoscale is the right choice for a personal productivity app. You'll use it throughout the day in bursts — it handles the first-request cold start quickly, and the $0 idle cost makes sense for a personal tool that's offline much of the time.

### Can RapidDev help build a custom productivity workspace for my team?

Yes. RapidDev can extend this personal workspace into a team collaboration tool with shared workspaces, real-time editing, and role-based access — drawing from 600+ apps built for clients. Free consultation available.

### How does the daily planner connect tasks to specific dates?

The daily_plans table stores an ordered array of task IDs as JSONB for each (user_id, date) pair. When you drag tasks onto today's plan, the frontend sends PUT /api/daily/:date with the new task_ids array. The response joins those IDs against the tasks table to return full task objects in the saved order.

---

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