The pitch
Most habit and mood apps are a wall of inputs. Anchor is the opposite: two short rituals (one to open the day, one to close it) and a quiet trend line in between. Set an intention in the morning, log how you slept, breathe. Reflect and journal at night, prep tomorrow's sleep window. That's the whole product.
It started from a narrower question: how far can one Next.js codebase stretch across web, mobile, and desktop without forking the UI? The answer turned out to be "all the way," and that's the part worth showing.

The frame above is the real deployed app, the live web build running at anchorapp.cc. Touch it: start a ritual, drag the sleep slider, walk the steps.

A ritual, not a form
The opening screen is the product promise: two rituals, no wall of inputs. Warm paper-and-ink tones and a single action set the tone before the first tap.

The streak as an anchor
One number, two cards. The dashboard shows today's completion state and the running streak, the mechanism that makes skipping feel costly while staying gentle.

Touch-first controls
The sleep-quality slider is a Radix primitive reskinned to token. This is also the component behind the invisible-track bug, and the selector fix that earned its own paragraph in the write-up.

Close the loop
Mood, free journal, tomorrow's sleep window. The evening ritual reuses the same step-machine pattern as morning: different data shape, same interaction contract.
One codebase, three targets
The app is a standard Next.js 16 App Router project. The trick is that every route is client-rendered against local state (no SSR data dependency), which means the exact same build can run three ways:
- Web: deployed on Vercel, server build, the default.
- Mobile:
BUILD_TARGET=native next buildflips onoutput: "export", producing a static bundle that Capacitor wraps into native iOS and Android shells. - Desktop: the same static bundle, wrapped by
@capacitor-community/electronfor macOS / Windows / Linux.
The conditional in next.config.mjs is the whole seam:
const isNativeBuild = process.env.BUILD_TARGET === "native";
const nextConfig = {
...(isNativeBuild && {
output: "export",
images: { unoptimized: true },
trailingSlash: true,
}),
};Vercel keeps the full server build; npm run cap:sync produces the static export and copies it into the native projects. One UI, one component tree, shipped to all three targets from a single build.
Stack decisions worth defending
Next.js for an app that's mostly client-side. The product is a stateful flow, and App Router's file routing, font handling, and static-export escape hatch make it the path of least resistance to a Capacitor bundle. The editor lives on the client, so it skips server components entirely.
Radix primitives + Tailwind v4, styled to the token. The whole thing is warm, paper-and-ink, with a custom OKLCH palette. Radix gives accessible behavior (slider, dialog, tabs) and I style it down to the token. shadcn-style ownership of the component source let me fix things directly instead of fighting a library's defaults.
Framer Motion, used sparingly. Ritual steps cross-fade, the brand motif settles in, status cards slide. Animation is what makes a flow feel shipped rather than half-built, and every extra spring is a frame budget, so it stays restrained on purpose.
Quality pass: where implementation review mattered
I used an AI assistant to move faster on the first implementation pass, then treated the rendered UI as the source of truth. The clearest example: the sleep slider initially rendered as an invisible track, with only the thumb showing, floating on white.
The generated Tailwind looked plausible:
<SliderPrimitive.Track
className="... data-horizontal:h-2 ..." />Radix sets data-orientation="horizontal", so data-horizontal:h-2 matched nothing, the track collapsed to height: 0, and the rail vanished. The element had zero height, so the fix lived in the selector:
className="... data-[orientation=horizontal]:h-2 ..."That is the practical workflow: use AI for speed, then verify the interface like production code. Reading the rendered DOM surfaced the mismatch, and the fix landed at the component-contract level rather than as a visual band-aid.
What I'd do differently
- Persist state to IndexedDB from day one. The current build keeps entries in memory; production needs offline-first storage that survives a reload, which Capacitor makes easy but I deferred.
- Wire Sentry up front, before shipping. Cross-platform means cross-platform bugs; I'd want the crash data from the first native build.
- Lock the design tokens earlier. I iterated the palette in the components and had to reconcile it back into CSS variables. Tokens first, components second.
Numbers
- Targets from one codebase: 3 (web, mobile for iOS + Android, desktop).
- Production build: 7 static routes, clean typecheck + lint.
- Native seam: one env-gated config block, single shared UI.
- Time to cross-platform build: a single focused build session.
