demo(recipe): vanilla recipe app on the Firebase JS SDK - #797
Conversation
The plain-SDK half of the side-by-side comparison. A follow-up branch swaps in ReactFire, and the diff between the two is the deliverable. The homepage fetches the recipe list in a server component and hands it to a client component, which takes over with onSnapshot. Everything else is a client component. Search is absent by design, dropped from the requirements on 2026-08-18. Structured so the ReactFire swap touches five files and nothing else: firebase.ts, layout.tsx, session.ts, use-recipes.ts and a new providers file. No page and no client component imports firebase directly, which `grep -rl "firebase/" src/components` should keep returning empty. Verified against the repo's emulator suite on Node 26: - The recipe list is present in the server-rendered HTML from curl with no JS running, with a negative control on a title that does not exist. - Liking a recipe updates the count with no reload and no optimistic update, so the value came back through Firestore. - The cuisine filter returns only matching recipes. - Signing in redirects back through ?next=, and /create-recipe bounces to /signin when signed out without flashing the page. - tsc --noEmit and next build are clean. The typecheck was confirmed able to fail first, by injecting a type error. Not verified: /create-recipe calls Firebase AI Logic, which needs the product enabled on a real project. The call is written but has never run, and the button surfaces the raw error rather than catching it. AGENTS.md and CLAUDE.md are generated by `next dev` on every run and are committed so the tree stays clean. Their contents are Next's own instructions to coding agents, not ours.
armando-navarro
left a comment
There was a problem hiding this comment.
Almost everything below sits in the Firebase configuration, and I suspect most of it survived because the emulator loads the repo's open rules rather than the ones this PR ships.
What I would fix before merge
1. The next parameter on the sign-in page is an open redirect
signin/page.tsx hands searchParams.get('next') straight to router.replace. I loaded /signin?next=https://example.com/, signed in as the demo user, and the browser left the app and landed on example.com.
- Next's App Router deliberately hard-navigates when the origin differs, so the check has to live in the app rather than the framework.
- A protocol-relative value works the same way. I ran
/signin?next=//example.com/separately and it also left the site. - A leading-slash check is not enough on its own.
next=/\example.com/starts with a single/and still resolves tohttp://example.com/, because URL parsing folds backslashes into forward slashes. - What held up when I tested it was parsing and comparing the origin, then keeping only the path. Build
new URL(next ?? '/', window.location.origin), use it when its origin matches, and fall back to/otherwise. - That rejected the absolute, protocol-relative, backslash and
javascript:forms, and left ordinary paths with their query and hash intact.
2. One recipe document missing an array field takes the homepage down for every visitor
allow create: if request.auth != null puts no constraint on fields, so a signed-in user can write a recipe with no likedBy. RecipeCard reads recipe.likedBy.length, and because the list is server-rendered the whole page fails rather than one card.
- Measured against the demo's own rules: homepage 200, one such write, then homepage 500 with
TypeError: Cannot read properties of undefined (reading 'length'). ingredientsandstepsdo the same thing through.map, and I saw the same 200 to 500 transition for a document missingingredients. A missingtitleorcuisinerenders fine, so it is the three array and count fields that matter.- The
as Recipecast inrecipes.tsis what keeps the compiler quiet, so constraining the create rule is the fix rather than removing the cast. /create-recipeis a second route into theingredientsandstepsversion, because the model's JSON is parsed and written with nothing checking those fields. It cannot produce thelikedByone, sincecreateRecipesets that itself.
3. npm run seed cannot run against the rules this PR ships
The script writes unauthenticated while the create rule requires a signed-in user. I ran into this by accident while setting up another test, and it fails with 7 PERMISSION_DENIED: false for 'create' @ L11, false for 'update' @ L14.
- It works locally only because the emulator loads the repo root's open rules rather than this one.
- Signing in as the demo user the script already creates does fix the first write. I tried it against these rules and the seed-shaped document went through.
- There is no error handling, so the failure arrives as an uncaught
FirebaseErrorand a Node stack trace. - Re-running is still not idempotent even after that, because
setDocover an existing document counts as an update and the rule allows onlylikedByto change. - The script recomputes
createdAton every run, so the second pass alters a forbidden field. I ran it both ways: recomputedcreatedAtis denied, while a write that changes nothing at all is allowed. - Unrelated to the rules but in the same script: node does not read
.env.local, only Next does, so the variables.env.local.exampledocuments are ignored here entirely.
4. The cuisine filter needs a composite index that the PR does not ship
where('cuisine', '==', ...) combined with orderBy('createdAt', 'desc') is the case the Firestore docs describe as "if you need to sort by a different field, you must create a manual index for that query".
- The emulator does not require the index, which is why the filter check in your description passed. The root
firestore.indexes.jsonthe emulator loads declares no indexes, and the demo folder ships no index file of its own. .env.local.exampleoffersNEXT_PUBLIC_USE_EMULATORS=falsefor running against a real Firebase project, and in that setup the query needs an index that does not exist.- I would expect a cuisine selection there to land in the hook's error branch, though I have not run it against a deployed project, so that step is the docs rather than a measurement.
- The server render of the homepage is unaffected, since
page.tsxonly ever queries unfiltered. It is the filter interaction on that page that breaks.
5. Any signed-in user can set any recipe's like count to any number
This one is a rules change rather than app code. The comment above the update rule promises more than the rule delivers: membership is enforced, so a third party's new uid really is rejected, but the number of times a uid appears is not.
- The two set-difference checks both compare
toSet()results, and a set is blind to duplicates. - Acting as one user, I set another user's like count from 1 to 500 by writing 500 copies of their uid, never adding my own.
- Adding
request.resource.data.likedBy.size() == request.resource.data.likedBy.toSet().size()closes it. - I ran the rule with that line in place. It denied all three duplicate writes I tried: 500 copies of someone else's uid, 500 copies of my own, and the minimal
[uid, uid]. - The same run still allowed a like, an unlike, and a like alongside someone else's, and left the denials I could construct unchanged, including adding or removing another uid, editing the title, deleting, and anonymous writes.
- On its own that line is not sufficient, because the unconstrained create rule from item 2 lets a signed-in user create a recipe with
likedByalready stuffed. The two go together.
Optional, roughly in the order I would care about them
-
The ref in
useRecipesonly does its job once.renderedCuisineis set at mount and never updated, so returning to the cuisine the page was rendered with skipsloadingand reportslivewhile still holding the previous filter's list.- I measured the stale window at a few milliseconds, bounded by cache rather than network, so this is about the state machine rather than something users will see.
-
FeedStatusdeclares four states and the UI distinguishes one.RecipeBrowsertests onlyloadingand reads errors from the separate field, sohydratedandliveare the same thing to every consumer. -
A dead backend looks exactly like an empty database. With nothing listening on the Firestore port,
onSnapshotfires its success callback with zero documents and never calls the error callback, so the page renders "No recipes yet" with a 200 and no diagnostic. -
A failed like tells the user nothing.
onToggleLikeistry/finallywith nocatch.- On a denial the write applies locally and then reverts, so the like appears and silently undoes itself.
- Offline the promise never settles, so the button stays disabled and busy for good.
-
The generation path cannot work with the config the PR ships, for a reason worth knowing.
.env.local.exampleleavesNEXT_PUBLIC_FIREBASE_APP_IDempty, and Firebase AI requires it.- I ran it:
getGenerativeModelthrowsAI/no-app-id("The "appId" field is empty in the local Firebase config") before any network request happens. - So anyone following the example file hits that rather than anything about AI Logic being enabled. Filling in the app id, or saying in a README that this path needs one, would save the next person the same detour.
- Worth noting separately that there is no AI Logic emulator to point at, so this path always talks to the real service even when the rest of the app is on emulators. That is a documentation note, not something to wire up.
- I ran it:
-
gemini-3-flash-previewstill works, but a newer stable exists. It is on the AI Logic models page as the Preview version of Gemini 3.x Flash, published 2025-12-17 with no shutdown date announced.gemini-3.7-flashis the current stable, if you would rather a demo not pin a preview. -
createdAtcomes from the client clock while the public list is ordered by it, so a skewed or deliberately advanced clock pins a recipe to the top.serverTimestamp()would take that out of the client's hands. -
Neither
globalThisemulator sentinel is needed. BothconnectFirestoreEmulatorandconnectAuthEmulatordocument themselves as no-ops when the configuration matches, for exactly the repeated-call case the sentinels infirebase.tsandsession.tsare guarding against. -
The server-side read goes through the realtime SDK, so a one-shot query opens a watch stream.
fetchRecipesruns in a server component, but it uses the samefirebase/firestoreinstance the browser uses.- Running the same query against the same emulator, the full SDK issues RPC
Listenopened as a stream, whilefirebase/firestore/liteissuesRunQuery. The lite build requires no gRPC module at all, where the full one pulls@grpc/grpc-jsand@grpc/proto-loader. - It is not an import swap, which is why I am raising it rather than offering a one-liner. Lite has no
onSnapshot. - Acting on it would mean
src/lib/firebase.tshanding a lite instance to the server read and the full one to the live listener, and that file is where the ReactFire swap lands.
- Running the same query against the same emulator, the full SDK issues RPC
-
There is no README. The run order, the emulator dependency, deploying the rules, and the demo credentials live only in the PR description.
-
Nothing in CI touches
recipe-demo. The root tsconfig and the lint scope both exclude it, so the typecheck and build you ran are true today with nothing keeping them true. -
On
AGENTS.mdandCLAUDE.md. I scaffolded into an empty directory with Next's own generator and diffed: both files are byte-identical to its output, so you are right about where they come from. Two notes on the wording:- Generation is gated on Next detecting an AI agent, and it runs in
next devonly, nevernext build, so "on every run" is broader than what happens. agentRules: falseinnext.config.tsdisables it outright, which makes keeping the files a choice rather than something forced on the repo.
- Generation is gated on Next detecting an AI agent, and it runs in
If I have misread any of these, particularly the rules cases, point me at the one you think is wrong and I will run it again.
The plain Firebase JS SDK half of the ReactFire comparison from the demo requirements doc. A follow-up PR swaps in ReactFire against this branch, and the diff between the two is the deliverable.
The app
Recipe site on Next.js 16, React 19, picocss and firebase 12.17.1. Public recipe list with a cuisine filter, likes when signed in, email and password sign-in, route protection, and an AI Logic page that generates a recipe.
The homepage fetches the list in a server component using the JS SDK and hands it to a client component, which takes over with
onSnapshot. The list is public, so no identity is involved and the security rules stay in play. Everything else is a client component.Search is absent by design, dropped from the requirements on 2026-08-18.
Structured for the swap
src/lib/use-recipes.ts(42 lines) hand-rolls what the comparison is about: seed from the server's data, subscribe, and track'hydrated' | 'live' | 'loading' | 'error'so the list never flashes a spinner it does not need.No page and no client component imports Firebase directly.
grep -rl "firebase/" src/componentsreturns empty, which is what keeps the follow-up diff readable.Verification
Against the repo's emulator suite:
curlwith no JS running, with a negative control on a title that does not exist.?next=, and/create-recipebounces to/signinwhen signed out without flashing the page.tsc --noEmitandnext buildare clean, with the typecheck confirmed able to fail first by injecting a type error.Not verified:
/create-recipegeneration, which needs a real project with AI Logic enabled. The call is written and surfaces its error verbatim rather than catching it.One thing to know before reading the diff
AGENTS.mdandCLAUDE.mdin this folder are written bynext devon every run, fromnode_modules/next/dist/server/lib/generate-agent-files.js. They are Next's own instructions to coding agents, not ours. Deleting them only recreates the change, so they are committed to keep the tree clean.