Building Life OS: replacing Notion with a personal operating system I built from scratch
What it took to design and build a full personal productivity system — ten modules, a strict architecture, a custom design system, and a real Notion data migration — as a solo Next.js project.
There is a version of this story where I keep tweaking my Notion setup forever. That version exists, and I lived it for a while. Multiple linked databases, relation properties, rollup columns, carefully named views, a review template I maintained weekly. It worked well enough until it started feeling like I was maintaining the system instead of using it.
The frustration was not Notion's fault. It is a general-purpose tool, and general-purpose tools carry configuration overhead. Every time I wanted a subtle behavior change — how tasks sort, when something appears in today's view, what happens when I complete a habit — I was working against the grain of what Notion is. The customization was always one level removed from what I actually wanted.
So I built Life OS.
The idea: one loop, one app
The product decision that shaped everything was to organize the app around a single loop rather than a set of features:
Capture → Process → Plan → Execute → Review → Repeat
Every screen in the app serves exactly one role in that loop. The Inbox is for capture. Home is for execution. Calendar is for planning. The weekly and quarterly review screens are for stepping back. Projects, Goals, Habits, Learning, and Notes are for all the supporting context that makes the loop run.
That constraint sounds obvious but it has real consequences. It means certain features get excluded entirely. No team features, no comments, no contact management, no financial charts. Every time a feature request came up during development, the question was: where does this live in the loop? If it does not fit cleanly, it does not go in.
The result is an app that does less than Notion in absolute terms but does what I actually need without any ceremony.
Designing the visual system before writing code
The one thing I did not want to prototype my way into was the visual design. A personal system you interact with every day needs to feel a specific way, and I knew from past projects that retrofitting a design system is painful. So I decided the visual identity before I wrote a single component.
The system is called Variant A — Warm Structured. The core constraint is that everything should feel calm and a little analog. Not cold, not high-contrast, not gamified.
The page background is #f5f1eb — warm parchment. The sidebar is #eee9e0. The single interactive color across the entire app is #4f6b8a (slate blue), used for active nav items, checkboxes, buttons, progress bars, and links. There is a warning color (#c97c3a) for overdue items and deadlines. Everything else is text and borders in warm neutrals.
The typography is DM Sans at three weights: 300, 400, and 500. The decision to stop at 500 was deliberate. 600 and heavier would make the UI feel heavy and urgent, which is the opposite of what a daily system should communicate. The muted labels (section headers, metadata) are 10px, uppercase, with letter spacing — small enough to recede, legible enough to scan.
Some of the details that matter most are the ones that are hard to describe: borders at 1px instead of 0.5px, which makes the layout feel warmer; habit pips at 9×9px with 2px radius; progress bars at 3px. None of these are dramatic choices individually. Together they make the UI feel sized for reading rather than for interaction.
The architecture: server components, service layer, server actions
The stack is Next.js 14 App Router with TypeScript in strict mode throughout. I made three architectural decisions early on that I enforced for the entire project.
First, default to server components. Client components only get 'use client' when they genuinely need it — React hooks, browser APIs, event handlers that cannot be server actions. Initial data always comes from server components as props. There is no useEffect + fetch pattern anywhere in the app.
Second, all database access lives in src/services/. The db export from Drizzle is imported only in service files. Features never touch the database directly. This was a strong rule to enforce from day one because it made every service function trivially composable: they receive userId as an explicit parameter and return typed data, with no auth logic embedded inside them.
Third, all mutations are server actions. Every create, update, and delete goes through a server action in features/*/actions.ts. The pattern is always the same: get the user session, validate input with the same Zod schema used for client-side form validation, call the service function, revalidate the affected paths, return { success: true } or { error: string }. No API route handlers for mutations, no fetch calls to internal endpoints.
This architecture has a cost — you cannot just call a service function from anywhere — but it has a much larger benefit: the data flow is always predictable. Every page knows exactly where its data comes from. Every mutation knows exactly what it is allowed to touch.
Building the ten modules
The app has ten distinct modules. Most are fairly self-contained. A few pushed harder.
The Inbox was the first thing I built after auth and schema, because it anchors the loop. Items land here without context — no area, no due date, no project. The processing step is deliberate: you move an item out of the inbox by assigning it somewhere, not by setting a due date and calling it done.
The Home page is the execution surface. It has a stat row (tasks due today, overdue, this week, total), a today task list, and a daily plan panel on the right. After migrating my real data, I added sort controls — Default, Project, and Priority — because my work tasks cluster by project and I wanted to see them grouped without filtering. That addition came from actual use, not spec.
The Calendar was the most technically involved. Week and month views with navigation, click-to-edit event detail, and new events that appear in local state immediately rather than waiting for a server round trip. The month view grid is six weeks, Monday-anchored, with a "+N more" cap per day. On mobile it defaults to month view; the week view is horizontally scrollable.
Habits had the most interesting post-launch bug. After migrating my habit data, completions were showing on the wrong days. The issue was that completion records were being written and read against UTC midnight rather than my local timezone (America/Guayaquil, UTC-5). A completion at 11 PM local time was landing on the next calendar day in the database. The fix involved storing the date as a date string in the user's profile timezone and doing all comparisons against that rather than raw timestamps. Once fixed I also had to manually correct several weeks of existing records.
The Learning module was the most satisfying to build. Learning paths contain modules, and each module has a TipTap editor for notes that auto-saves with a debounce. The layout is a two-column design: the left column has the TipTap editors for each module stacked vertically; the right column has a panel for the module checklist, which scroll-syncs with whichever module the user is viewing. Clicking a module in the right panel scrolls the left column to that section. It feels more like a focused study environment than a task list.
The Notion migration
After finishing all ten phases, I migrated my actual data from Notion. This was the real test.
I brought over active projects, tasks from three work streams (with status, area, project link, and priority), learning paths with their full module lists, and personal goals. The migration was done via SQL scripts against the Supabase Postgres database.
The migration surfaced three bugs I would not have found any other way.
The first was the timezone issue described above — habit completions on wrong dates. The second was task invisibility: my work tasks had been imported with status in_progress, but the today view query filtered for todo. Three weeks of real work tasks were in the database but not showing up. Fixed with a SQL update and a query adjustment. The third was a calendar week-boundary bug where navigating to the next week sometimes re-fetched the wrong range depending on the day of the month.
All three were found and fixed during the first week of real use. The app has been my primary daily system since then.
What I would do differently
Two things.
The Drizzle schema grew in a single file (src/db/schema.ts) across all ten tables. For a personal project this is fine, but for anything larger I would split it by domain early. The single file became long and required scrolling to cross-reference relations.
The second is that I added mobile responsiveness late — after all ten phases were done — rather than designing for it from the beginning. The responsive pass was not difficult because the architecture was clean, but doing it after the fact meant I had to review every page individually rather than having a responsive foundation I could build on.
Why it works as a daily system
The thing that surprised me most about Life OS is how different it feels to use a system you built versus a system you configured. The difference is not cosmetic. When something annoys me, I can fix it in an afternoon. When I want a new behavior, I know exactly which service function and server action to touch. The loop I defined is the actual loop I run every day, not a metaphor for how I use a bunch of loosely related features.
The constraint of building for one user also clarified a lot of decisions that are usually ambiguous. Multi-user systems have to be flexible. Single-user systems can be opinionated. Life OS is opinionated about nearly everything — the sorting, the stat calculations, the way the review screen prompts work — and that makes it faster to use and easier to maintain than any general tool could be.
The loop runs the same way every day. The system finally matches it.