valentyn-vb / valentyn-vb/FoodNote

Modernise the Next.js frontend: server-first architecture

Open
#86 0 comments 0 reactions 1 assignee View on GitHub

@riedel28 is already working on this.

Since Jul 31, 2026.

wayfinder:map
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 in AGENTS.md / docs/adr/. (app)/meals was 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.ts in 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. cacheComponents was in scope and is now out (see Out of scope), so we stay on the previous caching model — use cache, cacheLife and updateTag are 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, /profile and the shell so each is a single tree, deleting the hidden lg:* twins. Freezing before that means the migration rewrites data flow through desktop-dashboard.tsx and mobile-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 under app/(app)/** plus four lib/ files — so the migration would have rewritten five files the freeze left formally open. #137 is assigned and open against meal-log-drawer.tsx right now. Full note on #110.
  • Amended 2026-08-04: (app)/weights exists, 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 calls useMeals(), and the frozen list is re-derived from #79's scope after #148 merges, reaching into components/ 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 onmiddleware.ts is now proxy.ts (nodejs only, no edge) and is explicitly not an authorization solution; auth checks in layouts are discouraged, a cache()-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; revalidateTag now needs a cacheLife profile and no longer re-renders, with updateTag/refresh new in 16; and use cache cannot read cookies at all. Full write-up in docs/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-Cookie server-side, and proxy.ts renews 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.ts is deleted and both providers are deleted outright — they hold server data and no UI state. GoalReachedOverlay needs 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, no useActionState. The two validations are not duplicates — the client validates the form schema, the action safeParses the request schema as a trust boundary, and Nest's pipe stays. Expected failures are return values, never throws (production redacts a thrown message): ActionResult<T> carries message + optional fieldErrors, applied into RHF via setError so the existing data-invalid/aria-invalid markup needs no change, and the action never decides where an error is drawn. The action is dispatched inside startTransition and isPending is the only pending state — formState.isSubmitting is never read again. One submit is one action even when it fans out. One backend change falls out: a transactional PUT /api/plan for onboarding only, because goals.create requires 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 turns secure. It lands as a CI job on pull_requestmain, continue-on-error: true until the freeze PR removes the flag. Fixtures are provisioned through the service layer (seedDemoAccount with a per-run email), not over HTTP — AUTH_THROTTLE is 5/min per IP on both register and login and 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 dedicated foodnote_e2e database with a unique email per run, so no reset step and no if (CI). Assertions are roles and visible text, with no data-testid: the NumberFlow stats get an accessible name instead, which is also a real a11y fix. The AI parse path is covered with an env-selected stub MealParser (justified as test infrastructure, not by the map's "removes a frontend workaround" rule), which also means no OPENAI_API_KEY in CI. The suite is a fourth workspace, e2e/; root npm test stays 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 liveno gate component survives. Nest is the authority implicitly: serverFetch in lib/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 local exp decode for entry points that read nothing. proxy.ts adds 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() returns Goal | null, requireOnboarded() / requireNotOnboarded() redirect on it, and because #89 already has every (app) page reading goal for 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-standing TODO(onboarding-forms). No unauthorized.tsx / forbidden.tsx / authInterrupts: no roles, and the flag is experimental. Both full-screen spinners go; every (app) route gets a loading.tsx with a skeleton of its own shape, and the sidebar paints instantly because the layout sits outside that boundary. AuthProvider is deleted (deferred here from #88), which buys one narrow, written-down exception to rule 4 — (app)/layout.tsx reads getCurrentUser(), 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 into app/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 is frontend/) with the workspace-aware install/build, plus serverActions.allowedOrigins by pattern, since every preview host is hashed. lib/server/env.ts becomes the only reader of process.env — Zod-parsed and imported by next.config.ts, so a missing API_URL fails the build rather than the first request to each cold serverless instance; the http://localhost:3001 default dies with the rewrite. The finding that was not in the ticket: once every request to Nest comes from a Vercel function, req.ip collapses to one egress address and AUTH_THROTTLE's 5/min per IP locks all users into one bucket — exactly the accident common/trust-proxy.ts:9 warns about — so serverFetch and proxy.ts forward the incoming x-forwarded-for, and TRUST_PROXY_HOPS is re-measured once via LOG_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 when proxy.ts refreshes an expired token against a sleeping Render, which is the normal next-day return path and cannot be covered by loading.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.ts or use-onboarding-status.ts until 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 very weight-context window 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 the continue-on-error removal and an AGENTS.md paragraph, so it can't sit in a review queue. The finding that reshaped the rest: api-client.ts fetches 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 sets Authorization, knowingly against the letter of #89's rule and licensed only because its teardown is written down (it dies with api-client.ts in 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)/meals was missing from the map entirely and folds into the dashboard PR, since MealsProvider dies 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 languagePlan joins CONTEXT.md as 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 through GET /goals/current and GET /profile; no GET /api/plan, because collapsing the profile page's three reads is convenience and the map's rule needs a workaround removed. The verb is POST, not PUT — #90's PUT was shorthand and idempotence is unattainable here, since each call appends to the append-only journal (ADR-0004) and marks the outgoing goal replaced — and a second call gets 409, so the endpoint says at the trust boundary exactly what requireNotOnboarded() 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 on PATCH /profile + PATCH /goals/current. Returns GoalResponse, no new schema — getCurrentGoal() !== null is the definition of onboarded. The body is flatcreatePlanRequestSchema = putProfileRequestSchema.extend({ currentWeightKg, targetWeightKg, preferredWeeklyChangeKg }) — which finally lets onboardingFormSchema derive from the request via one .omit(), inverting today's dependency and satisfying the Forms rule; recordedAt leaves the contract entirely, because startWeightKg is derived from that entry and a client clock must not get a say in where a goal starts. The transaction lives in a new PlanService that owns it and passes the EntityManager into the three existing services, rather than writing rows itself: the completed/replaced choice at goals.service.ts:112 is 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)/weights joins the dashboard slice mechanically, not by preference: the page is 'use client' and calls useMeals(), which #79 deletes outright, so it throws the instant /meals does — the verbatim (app)/meals argument, and the bridge cannot help because it restores api-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 outside app/(app)/**, and #148 moves stat-card.tsx out of app/(app)/dashboard/ — gaining weights/**, lib/weight-range.ts, stat-card, stat-chip, stepper-nav, weight-range-nav, day-nav and weight-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 itselfThe 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

  • cacheComponents and the use cache family#93, closed. In scope at charting time, removed once #87 showed the flag makes uncached dynamic access a build error app-wide while use cache cannot read cookies() 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.