Back to all projects

Simple Grocery List

Next.js · React · TypeScript · Tailwind CSS · Zustand · Motion

Porting a Supabase-backed, household-shared mobile app into a browser-only demo. Deleting the backend was the design decision, not a compromise. Most of the code existed to solve a problem a single-visitor demo does not have.

Live demoCode

Problem

The original is a real app my household uses: an Expo and React Native grocery list backed by Supabase, with accounts, shared households, invites, live presence, row-level security, and an offline queue. It works, and it stays running.

None of that survives contact with a portfolio link. A visitor arriving from my site would meet an account wall, then an invite flow, then a cold free-tier database, three obstacles before seeing a single list. And a free tier that sleeps after a week of inactivity eventually makes the link look broken rather than idle.

What a demo needs is the opposite of what a shared household app needs. The demo has to be usable within a second of arriving, by one person, with nothing to sign up for and nothing to wait on.

Constraints

No backend, no database, no auth provider, no environment variable, and that as a rule rather than a goal. If a feature appears to need one, the feature does not belong in the demo.

Data should not outlive the visit, which means sessionStorage rather than localStorage. A refresh or a deep link keeps your work; closing the tab clears it.

The repository began as a byte copy of the original, so it still pointed at live infrastructure: the production Supabase project, the live app's Vercel project, and the original git remote. A migration command run from that directory would have hit a production database.

The sign-in screens are worth keeping as an interface, but a fake login on a public site has to say so. Unlabeled, it reads as either broken or deceptive.

Approach

Sever the live connections before writing any code. Archive the original as a branch and a tag so it stays recoverable and reviewable, repoint the remote, delete the deployment link and the environment file, and only then start. Treating that as the blocking first task rather than cleanup was the whole difference between a safe port and an expensive one.

Delete first, scaffold second. Carrying both stacks in one package.json invites React Native and React DOM resolution conflicts for no benefit, and the archive branch already held everything worth keeping. Three files ported nearly verbatim: the barcode lookup, the color palette, and the domain types minus every multi-user field.

Let the constraint do the design work. sessionStorage is scoped per tab, so two tabs are two fully independent instances of the app. That is exactly the standalone behavior a demo needs, and it arrived for free: no tenancy model, no household id on anything, no row-level security, no presence.

Implementation

Next.js 16 on the App Router with React 19, TypeScript strict, Tailwind v4, shadcn/ui for primitives, Zustand for state, and Motion for gestures and layout animation. The whole app is client-side; the root layout stays a server component only for metadata and fonts.

About 900 lines of realtime, offline, and tenancy machinery (the household, lists, and presence providers, the member and network-status hooks, the offline queue) collapsed into a single store of roughly 150. That is the clearest measure of how much of the original existed to serve multi-user sync rather than the actual product.

The old app's iOS palette mapped almost one-to-one onto shadcn's token names, so it ported directly into CSS custom properties: the light set on the root, the dark set under both an explicit class and the system preference. One responsive shell handles both breakpoints: a grid that is a single column on phones and a sidebar plus detail pane above it, with the same routes behind both rather than duplicated screens.

First load is seeded with a populated list: items across three tags, a couple already checked, quantities and units filled in. A portfolio link that opens onto an empty state hides every feature the app has. The seed uses a fixed timestamp rather than the current time, so ordering is deterministic and the server and client cannot disagree on hydration.

Storage is never read during render. The store skips automatic hydration and rehydrates in an effect, components render a skeleton until it lands, and the hydration and mount flags are exposed through useSyncExternalStore rather than state set in an effect, which is both SSR-correct and what the React Compiler's lint rules require.

Barcode scanning uses the native detector in Chrome, Edge, and Android at zero bundle cost, and falls back to a zxing decoder that is dynamically imported so it only loads when someone actually taps Scan. Where the camera is unavailable or permission is denied, the Scan button is not rendered at all. A button that throws is worse than no button. The product lookup against Open Food Facts ported unchanged, since it was always a plain CORS-enabled request with no database behind it.

The demo auth validates that credentials look like credentials and stores no password, hashed or otherwise. There is nothing to authenticate against, so keeping one would be pure liability. Every auth screen carries a permanent line saying no account is created and nothing is sent to a server, and the old settings copy promising that your data is securely stored is gone. It would have been false here.

Challenges

The duplicate rule lost the thing that enforced it. An unchecked item cannot appear twice in the same list, and in the Supabase version that was guaranteed twice over: a unique partial index in Postgres plus a check in the interface. The index went with the database, so the store became the only place the invariant can live. It is enforced on adding and on renaming, the rename case excluding the item being renamed, and deliberately not re-implemented in any component: two enforcement points that can disagree is worse than one that cannot.

Swipe-to-delete was free on React Native and hostile on the web. Rebuilding the gesture with a drag constrained to one axis was straightforward; the problem is that a swipe is unusable with a mouse and invisible to a keyboard, and most portfolio visitors are on a laptop. So the row body is also a button that opens the edit dialog, the dialog carries Delete, and desktop gets hover-revealed controls on top. Every destructive action has a keyboard path, and the gesture is an enhancement rather than the way in.

The performance claim was asserted for a while before it was measured, and the first attempt to measure it was wrong in a way that looked right. It identified libraries by grepping the built output for internal identifiers, names that minification renames, and concluded that two libraries were in zero chunks. They had been in the bundle the entire time. Redoing it from source maps produced numbers that were roughly the same shape and specifically different.

The share metadata looked complete and was not. Title, description, site name, type, and a Twitter card were all present; the image and the base URL were missing. The card type declared was the one that promises a large preview image, which made the omission worse than declaring nothing. A shared link rendered as a bare text card instead of the compact card the smaller type would have produced.

Solution

First-load JavaScript, gzipped, went from 731 kB on every route to between 199 and 257 kB depending on the route. The method matters more than the number: build both versions, serve them, and count only what the served HTML actually tells the browser to fetch, attributing libraries from source maps rather than from the minified text.

The honest caveat is in the repository next to the numbers. That comparison is not like-for-like. The old bundle also carried the Supabase client, realtime subscriptions, the offline queue, and the household model, all of which the demo deleted rather than ported, so some unknown share of the reduction is scope rather than tooling. And most of the win is route splitting rather than the framework: the old export produced a single entry bundle with no splitting at all, so every visitor downloaded the barcode scanner, the settings screen, and the onboarding flow in order to look at a sign-in form.

The three specific bundle claims were checked individually rather than assumed. The icon library tree-shakes, with 21 icon modules reaching the build out of roughly 1,500 shipped. The zxing decoder stays in a chunk no route's first load references. The animation library is out of the initial payload on three routes of four. It loads immediately on the item view, which is correct rather than a regression, since that route's drag and animated reorder are visible right away.

Outcome

The demo opens onto a working, populated list with no account, no waiting, and nothing to provision. Installing dependencies and starting the dev server is the entire setup. There are no environment variables to get wrong, which is a genuine simplification rather than a limitation to apologize for.

The original household app is untouched and still running, and the pre-port implementation is preserved on an archive branch and tag, so the comparison the case study rests on is reproducible by anyone who clones the repository.

The port is written up as a decision record rather than a changelog, including the parts that went wrong: the mismeasured bundle, the metadata that shipped incomplete, and the features deliberately dropped along the way, such as voice input, which removed roughly 600 lines, a microphone permission prompt, and a browser support gap.

What I Learned

Removing infrastructure can be the feature. Roughly 900 lines of sync, presence, and tenancy code existed to solve a problem this audience does not have, and deleting it made the app faster to load, faster to open, and dramatically simpler to reason about. The instinct to treat a backend as a sign of seriousness is worth resisting when nothing in the product needs one.

A well-chosen constraint pays twice. sessionStorage was picked for one reason (data should not outlive the visit), and it happened to deliver per-tab isolation, which is what made the entire multi-user model unnecessary rather than merely unused. That is worth looking for deliberately: the constraint that also answers a question you had not gotten to yet.

An unverified performance number is a liability, and the obvious way to verify it can be confidently wrong. Keying on identifiers that minification renames produced a clean, plausible, completely incorrect result. Any conclusion that could have come out of a text search over built output deserves suspicion; the build's own source maps are the source of truth.

Honest labeling is part of the engineering. The single most important line removed in this port was a settings screen claiming data was securely stored. It was inherited, it was false in the new context, and it is exactly the kind of thing a careful reviewer notices first.