valentyn-vb / valentyn-vb/FoodNote
Modernise the Next.js frontend: server-first architecture
@riedel28 is already working on this.
Since Jul 31, 2026.
- Dominant language
- TypeScript
- Stars
- 1
- Forks
- 1
- PR merge metrics
- No merged PRs in 30d
Description
Destination
A settled, written architecture for the FoodNote frontend on Next 16 — where the session lives, what runs on the server, who owns form validation, and what is cached — turned into executable GitHub tickets covering every route group: (auth), (onboarding), (app), and the marketing landing. AGENTS.md and the ADRs are updated so the next feature is written that way by default. The map closes when nothing is left to decide and only the coding remains.
Notes
Domain. frontend/ is Next 16.2.10 + React 19.2.4 on the App Router, but written as if it were a Vite SPA: no loading.tsx, no error.tsx, no proxy.ts, no Server Action, no async page. Reads go through hand-rolled providers (lib/meals-context.tsx, lib/weight-context.tsx) that each run a useEffect with a cancelled flag, keep their own status, expose retry() via a reloadKey, and fan mutations out through callbacks — cache invalidation written by hand. Auth gates (app/(app)/layout.tsx, components/onboarding-guard.tsx) are client-side spinner → router.replace.
The blocker under everything. The access token lives in a module variable (lib/api-client.ts:40), and Nest's refresh cookie is path-scoped to /api/auth (backend/src/auth/auth.controller.ts:37), so it is not sent on a document request to /dashboard. No Server Component can authenticate today. Access TTL is 15m, refresh 7d; JwtAuthGuard accepts Authorization: Bearer only and nothing else; respondWithTokens strips the refresh token out of the JSON body, so a Next BFF cannot read it from the response body without either parsing Set-Cookie or a backend change.
Deployment. Backend: Docker → Render (.github/workflows/cd.yaml). Frontend: already live on Vercel via the GitHub App integration — production from main, a preview on every push — which is why no workflow or vercel.json exists in the repo (README's Deploy topology, corrected in 151b90a after this map was charted). Two origins, so browser traffic stays same-origin through the /api/* rewrite in next.config.ts while server-side reads go Vercel → Render directly, needing API_URL in the Vercel runtime env.
Skills every session should consult. /grilling and /domain-modeling by default. frontend/AGENTS.md is binding: "This is NOT the Next.js you know" — read the relevant guide under node_modules/next/dist/docs/ before writing any code or asserting any API. Root AGENTS.md owns the Forms and Styling rules this effort will have to amend, and CONTEXT.md owns the domain vocabulary.
Standing preferences for this effort.
- Planning only. Tickets resolve decisions; the code lands later as ordinary PRs.
- Scope covers all four surfaces:
(app)(dashboard + meals + profile),(auth)+(onboarding), the marketing landing, and the written conventions inAGENTS.md/docs/adr/.(app)/mealswas missing from this list until #110 found it. - The NestJS backend is in scope, but minimally: change it only where it removes a workaround from the frontend, and update
backend/src/docs/openapi.tsin the same change. - Modern Next.js done properly, not a feature checklist: Server Components, Server Actions,
loading.tsx/error.tsx,<Suspense>streaming, server-side redirects, a Data Access Layer.cacheComponentswas in scope and is now out (see Out of scope), so we stay on the previous caching model —use cache,cacheLifeandupdateTagare not available to us and should not appear in any ticket. - Sequencing: the dashboard is frozen, then migrated in one piece rather than incrementally alongside the team.
after #68, #69 and #70 land— corrected by #110: the freeze waits on PRs #106 and #76, not on #70, which is dropped from the pre-freeze set and written after the migration instead. The full eight-PR order lives in #110. - Amended 2026-08-03: map #121 goes first, and the freeze waits for it. One tree per screen rebuilds the layout of
/dashboard,/meals,/profileand the shell so each is a single tree, deleting thehidden lg:*twins. Freezing before that means the migration rewrites data flow throughdesktop-dashboard.tsxandmobile-dashboard.tsx, and #121 then re-lays-out the result days later — the dashboard built twice, which is the same argument that pushed #70 behind the migration. Doing layout first means the migration lands on one component per screen instead of two. The freeze therefore waits on #128, #129, #130, #131 and #112 as well as #76. Cost, accepted: the dashboard stays on the hand-rolled providers longer, so the #75 class of bug stays live, and the e2e job stays non-blocking until the freeze. The freeze's definition — the file list in #110 — is unchanged, as is everything after it. Done: #121 closed 2026-08-04, its execution carried by #143 together with #140; the pre-freeze queue is now just #142 and #143. - Amended 2026-08-04: five files join the frozen list —
(app)/layout.tsx,meal-log-drawer.tsx,weight-log-drawer.tsx,weight-history-row.tsx,goal-reached-overlay.tsx. #79's scope names them, but they never made it back into #110's list, which is paths underapp/(app)/**plus fourlib/files — so the migration would have rewritten five files the freeze left formally open. #137 is assigned and open againstmeal-log-drawer.tsxright now. Full note on #110. - Amended 2026-08-04:
(app)/weightsexists, and joins the dashboard slice. #70 was built early — PR #148, held open across the freeze under a one-time exception (#149) — so a route the map never inventoried is on the tree before the migration. It comes with the dashboard PR because its page callsuseMeals(), and the frozen list is re-derived from #79's scope after #148 merges, reaching intocomponents/for the first time. Full note on #149. - A thin Playwright smoke suite goes in before the migration, on the current code.
Related existing work. #79 Move dashboard data fetching to Server Components and Server Actions is the dashboard slice of this map — a detailed, already-written spec. It stays; this map decides the things it deliberately left open (app-wide session transport, validation ownership, the other route groups) and amends it where a decision contradicts it.
Decisions so far
-
Next 16: the facts this migration bets on —
middleware.tsis nowproxy.ts(nodejs only, no edge) and is explicitly not an authorization solution; auth checks in layouts are discouraged, acache()-memoized Data Access Layer is the recommendation;cookies()can be read anywhere on the server but written only in Server Actions and Route Handlers, so no Server Component can refresh a token; Server Actions dispatch one at a time per client and are public POST endpoints;revalidateTagnow needs a cacheLife profile and no longer re-renders, withupdateTag/refreshnew in 16; anduse cachecannot read cookies at all. Full write-up indocs/research/next-16-facts.md. -
Where the session lives — Next becomes the BFF and Nest is not touched: two Next-owned httpOnly cookies on the Vercel domain hold the raw Nest JWTs, login lifts the refresh token off Nest's
Set-Cookieserver-side, andproxy.tsrenews the 15-minute access token when it expires, writing it both to the browser and into the current render's request headers. The browser's direct/api/*path to Nest is closed, so the rewrite is deleted and every client-initiated call goes through a Route Handler or Server Action.SameSite=Lax. -
The server/client boundary, and what becomes of api-client.ts — six numbered rules bound for
AGENTS.md: server by default,'use client'as deep as possible and never on a layout or page, server data crosses as props and never through context, data is read at page level because layouts go stale on navigation, client components reach the server only via Server Actions (writes) and Route Handlers (interactive reads needing cancellation), and Zod response parsing lives only in the server data layer.api-client.tsis deleted and both providers are deleted outright — they hold server data and no UI state.GoalReachedOverlayneeds no redesign; it is already state-derived. -
Who owns form validation: react-hook-form or Server Actions — react-hook-form keeps the form, Server Actions become the write transport; no progressive enhancement, no
action={…}wiring, nouseActionState. The two validations are not duplicates — the client validates the form schema, the actionsafeParses the request schema as a trust boundary, and Nest's pipe stays. Expected failures are return values, never throws (production redacts a thrownmessage):ActionResult<T>carriesmessage+ optionalfieldErrors, applied into RHF viasetErrorso the existingdata-invalid/aria-invalidmarkup needs no change, and the action never decides where an error is drawn. The action is dispatched insidestartTransitionandisPendingis the only pending state —formState.isSubmittingis never read again. One submit is one action even when it fans out. One backend change falls out: a transactionalPUT /api/planfor onboarding only, becausegoals.createrequires an existing weight entry and that invariant currently leaks into the frontend's call order; profile editing keeps its three calls and stays non-atomic by choice. -
A Playwright smoke net over today's app, before anything moves — the net runs against the real stack (dockerised Postgres, real Nest,
next build && next start), because after #88 there is no/api/*left in the browser for a stub to intercept; the backend must stay non-production or auto-migrations stop and the refresh cookie turnssecure. It lands as a CI job onpull_request→main,continue-on-error: trueuntil the freeze PR removes the flag. Fixtures are provisioned through the service layer (seedDemoAccountwith a per-run email), not over HTTP —AUTH_THROTTLEis 5/min per IP on bothregisterandloginand is policy, not a knob; the #42 demo account can't be the fixture because the seed is idempotent by skipping and never repairs what a delete-scenario removed. State lives in a dedicatedfoodnote_e2edatabase with a unique email per run, so no reset step and noif (CI). Assertions are roles and visible text, with nodata-testid: theNumberFlowstats get an accessible name instead, which is also a real a11y fix. The AI parse path is covered with an env-selected stubMealParser(justified as test infrastructure, not by the map's "removes a frontend workaround" rule), which also means noOPENAI_API_KEYin CI. The suite is a fourth workspace,e2e/; rootnpm teststays unit-only. The ticket's own "Done when: green on main" is superseded — the suite lands as an ordinary PR. -
Where the auth and onboarding gates live — no gate component survives. Nest is the authority implicitly:
serverFetchinlib/server/is the only door to data (no cookie or a 401 →redirect('/login')), so the check cannot be forgotten because the data cannot be had without it;verifySession()stays as a cheap localexpdecode for entry points that read nothing.proxy.tsadds an optimistic cookie-only redirect on top of its #88 refresh — it has already read the cookies, and it covers the one case the data layer structurally cannot, an already-signed-in user on(auth)— carrying a validated?next=(single leading/, never//or/\). Onboarding is a checked precondition, not a 404 mapping:getCurrentGoal()returnsGoal | null,requireOnboarded()/requireNotOnboarded()redirect on it, and because #89 already has every(app)page readinggoalfor the overlay,cache()makes it free. The loop is now impossible arithmetically — the two conditions are negations over one memoized read — which closes the long-standingTODO(onboarding-forms). Nounauthorized.tsx/forbidden.tsx/authInterrupts: no roles, and the flag is experimental. Both full-screen spinners go; every(app)route gets aloading.tsxwith a skeleton of its own shape, and the sidebar paints instantly because the layout sits outside that boundary.AuthProvideris deleted (deferred here from #88), which buys one narrow, written-down exception to rule 4 —(app)/layout.tsxreadsgetCurrentUser(), because identity does not vary by route — and obliges the profile-edit action to revalidate the layout. The root layout stops knowing about the session entirely, so the landing reads the cookie itself and/becomes dynamically rendered, deliberately.(onboarding)collapses intoapp/onboarding/. -
Deploying the frontend, and what a cold Render start does to a server render — the ticket's premise was stale: the frontend is already deployed on Vercel via the GitHub App, previews and all. That integration stays; what joins the repo is
frontend/vercel.json(the project's Root Directory isfrontend/) with the workspace-aware install/build, plusserverActions.allowedOriginsby pattern, since every preview host is hashed.lib/server/env.tsbecomes the only reader ofprocess.env— Zod-parsed and imported bynext.config.ts, so a missingAPI_URLfails the build rather than the first request to each cold serverless instance; thehttp://localhost:3001default dies with the rewrite. The finding that was not in the ticket: once every request to Nest comes from a Vercel function,req.ipcollapses to one egress address andAUTH_THROTTLE's 5/min per IP locks all users into one bucket — exactly the accidentcommon/trust-proxy.ts:9warns about — soserverFetchandproxy.tsforward the incomingx-forwarded-for, andTRUST_PROXY_HOPSis re-measured once viaLOG_CLIENT_IP; no backend code changes. Cold starts get nothing — no keep-alive, no paid instance (measured: 22.5 s cold, 0.17 s warm; no timeout, since Hobby functions default to 300 s) — and that knowingly accepts the one real regression, a blank tab for up to a minute whenproxy.tsrefreshes an expired token against a sleeping Render, which is the normal next-day return path and cannot be covered byloading.tsx. Previews point at the production backend (team-only behind Vercel protection), against a written rule that destructive scenarios run on your own account, never the demo one. Vercel's function region is pinned to Render's, since each authenticated render is now several sequential cross-service round trips. Demo gets one checklist line and a README paragraph. Backend CORS is deliberately left alone — dead weight after the migration, but removing it clears no frontend workaround. Nothing left to decide: this is one execution PR. -
Calling the dashboard freeze, and amending the Server Components ticket — the freeze is a rule about files, not about the team's pace: nothing touches
(app)/dashboard/**,(app)/meals/**, both providers,api-client.tsoruse-onboarding-status.tsuntil the dashboard PR lands, bug fixes included and no exception for the demo, while styling, the backend and every other route carry on. It waits on PRs #106 and #76 — not on #68/#69/#70: #70 is dropped from the pre-freeze set (its range control replaces the veryweight-contextwindow this map deletes, so building it now means building it twice in two different shapes) and is written after the migration, server-first; #76 is finished and merged on its own rather than folded in. The freeze PR carries only thecontinue-on-errorremoval and anAGENTS.mdparagraph, so it can't sit in a review queue. The finding that reshaped the rest:api-client.tsfetches through the/api/*rewrite that #88 deletes, and its callers are the whole frontend — so the slices cannot migrate independently as the map assumed. A transitional bridge restores that,app/api/[...path]/route.ts, a dumb catch-all proxy that reads the access cookie and setsAuthorization, knowingly against the letter of #89's rule and licensed only because its teardown is written down (it dies withapi-client.tsin the last PR). It rejects/api/auth/*— login must set httpOnly cookies and client JS cannot, and special-casing it would write the security-sensitive code twice — so(auth)is not a slice, and neither is the landing's session-awareness; both ride with the session PR.(app)/mealswas missing from the map entirely and folds into the dashboard PR, sinceMealsProviderdies there. Eight PRs, in order: infrastructure → e2e → (#106, #76) → freeze → session → dashboard → profile → onboarding → cleanup, the first two starting immediately because they are new files that collide with nothing. #79 is amended in place, keeping its Goal/Context and Done when. -
POST /api/plan: the contract, and what a Plan is in the language — Plan joins
CONTEXT.mdas a real term: the committed triple of Profile + first Weight Entry + Goal, i.e. what a Plan Option (already in the glossary) becomes when chosen. Write-only by design — read a Plan back throughGET /goals/currentandGET /profile; noGET /api/plan, because collapsing the profile page's three reads is convenience and the map's rule needs a workaround removed. The verb isPOST, notPUT— #90'sPUTwas shorthand and idempotence is unattainable here, since each call appends to the append-only journal (ADR-0004) and marks the outgoing goalreplaced— and a second call gets 409, so the endpoint says at the trust boundary exactly whatrequireNotOnboarded()says at the routing layer (which matters: Server Actions are public POST endpoints). ADR-0003's "POST replaces" is deliberately not copied, because replacing a Goal is normal while replacing a Plan drags a spare weight entry along on any double submit; changing a plan later stays onPATCH /profile+PATCH /goals/current. ReturnsGoalResponse, no new schema —getCurrentGoal() !== nullis the definition of onboarded. The body is flat —createPlanRequestSchema = putProfileRequestSchema.extend({ currentWeightKg, targetWeightKg, preferredWeeklyChangeKg })— which finally letsonboardingFormSchemaderive from the request via one.omit(), inverting today's dependency and satisfying the Forms rule;recordedAtleaves the contract entirely, becausestartWeightKgis derived from that entry and a client clock must not get a say in where a goal starts. The transaction lives in a newPlanServicethat owns it and passes theEntityManagerinto the three existing services, rather than writing rows itself: thecompleted/replacedchoice atgoals.service.ts:112is the only place that status is ever set, and ADR-0007 exists to keep it that way. -
#70 landed early: PR #148, and what the freeze does about it — the collision #110 predicted did not happen: PR #148 never touches
weight-context.tsx, because widening the shared 60-day window to a year would make every dashboard figure re-derive over a range the dashboard never asked for, so the "built twice" cost is one 59-line hook rather than a rewritten provider. PR #148 is therefore held open across the freeze under a one-time exception written by number — it merges when review completes, ahead of the session PR, and PRs opened after the freeze date get none; holding it to the end of the migration was rejected because its diff overlaps six files the dashboard PR rewrites, so it would need a rebase across a full rewrite on a branch whose data plumbing is wrong by then.(app)/weightsjoins the dashboard slice mechanically, not by preference: the page is'use client'and callsuseMeals(), which #79 deletes outright, so it throws the instant/mealsdoes — the verbatim(app)/mealsargument, and the bridge cannot help because it restoresapi-client, not a context. Its migrated shape is a server page passing goal + initial journal as props with the range change on a Route Handler (#89 rule 5), making the dashboard slice three routes. The frozen list is re-derived from #79's scope once #148 merges rather than extended by one line — it has under-counted twice, both times on files outsideapp/(app)/**, and #148 movesstat-card.tsxout ofapp/(app)/dashboard/— gainingweights/**,lib/weight-range.ts,stat-card,stat-chip,stepper-nav,weight-range-nav,day-navandweight-trend-chart, at the accepted cost that the last two are not edited for design or for bugs until the migration lands. A CI check on the list is explicitly not the answer to how this recurs: it would not have stopped #148, which was legal because the freeze had not been called. What #148 brought beyond #70 — Testing Library, jsdom, coverage thresholds — is a repo-wide decision nobody made and is a review condition on the merge.
Not yet specified
One ticket, and it is about the handoff itself — The handoff: eight PRs, one issue, and where a sequencing rule has to live. #149 found that #110's ruling on #70 lived only in this map's Notes and in a closed ticket's comment, while #70's own body said nothing about it and only one of the eight PRs exists as an issue. So the route to the destination is walked, but the handoff — the thing this map owes on its way out — is not built. The map does not close until #150 is answered.
Everything else: #110 walked the last of the route — the slices are no longer "one ticket each", their contents and order are fixed, (auth) and the landing's session-awareness fold into the session PR, (app)/meals folds into the dashboard PR and (app)/weights joins it too (#149). #114 closed the last open decision. All of it is execution.
The handoff — everything below has nothing left to decide and becomes ordinary GitHub tickets rather than tickets here: the eight PRs in #110's order, the POST /api/plan PR carried by the onboarding slice (#114), and the write-up of this map's rules into AGENTS.md and docs/adr/ — one ADR or several is the author's call, but the 409-not-replace decision needs one of its own.
Out of scope
cacheComponentsand theuse cachefamily — #93, closed. In scope at charting time, removed once #87 showed the flag makes uncached dynamic access a build error app-wide whileuse cachecannot readcookies()at all, leaving no FoodNote read eligible and only the experimental, browser-memory-only'use cache: private'as a per-user hatch. The marketing landing was the sole beneficiary — not worth obligating every other route. Consequence: we remain on the previous caching model (02-guides/caching-without-cache-components.md).
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.