Skip to content

demo(recipe): vanilla recipe app on the Firebase JS SDK - #797

Open
tyler-reitz wants to merge 1 commit into
mainfrom
recipe-demo
Open

demo(recipe): vanilla recipe app on the Firebase JS SDK#797
tyler-reitz wants to merge 1 commit into
mainfrom
recipe-demo

Conversation

@tyler-reitz

Copy link
Copy Markdown
Contributor

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/components returns empty, which is what keeps the follow-up diff readable.

Verification

Against the repo's emulator suite:

  • Recipe list present in the server HTML from curl with no JS running, with a negative control on a title that does not exist.
  • Liking 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, with the typecheck confirmed able to fail first by injecting a type error.

Not verified: /create-recipe generation, 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.md and CLAUDE.md in this folder are written by next dev on every run, from node_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.

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 armando-navarro left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to http://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').
  • ingredients and steps do the same thing through .map, and I saw the same 200 to 500 transition for a document missing ingredients. A missing title or cuisine renders fine, so it is the three array and count fields that matter.
  • The as Recipe cast in recipes.ts is what keeps the compiler quiet, so constraining the create rule is the fix rather than removing the cast.
  • /create-recipe is a second route into the ingredients and steps version, because the model's JSON is parsed and written with nothing checking those fields. It cannot produce the likedBy one, since createRecipe sets 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 FirebaseError and a Node stack trace.
  • Re-running is still not idempotent even after that, because setDoc over an existing document counts as an update and the rule allows only likedBy to change.
  • The script recomputes createdAt on every run, so the second pass alters a forbidden field. I ran it both ways: recomputed createdAt is 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.example documents 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.json the emulator loads declares no indexes, and the demo folder ships no index file of its own.
  • .env.local.example offers NEXT_PUBLIC_USE_EMULATORS=false for 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.tsx only 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 likedBy already stuffed. The two go together.

Optional, roughly in the order I would care about them

  • The ref in useRecipes only does its job once. renderedCuisine is set at mount and never updated, so returning to the cuisine the page was rendered with skips loading and reports live while 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.
  • FeedStatus declares four states and the UI distinguishes one. RecipeBrowser tests only loading and reads errors from the separate field, so hydrated and live are the same thing to every consumer.

  • A dead backend looks exactly like an empty database. With nothing listening on the Firestore port, onSnapshot fires 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. onToggleLike is try/finally with no catch.

    • 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.example leaves NEXT_PUBLIC_FIREBASE_APP_ID empty, and Firebase AI requires it.

    • I ran it: getGenerativeModel throws AI/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.
  • gemini-3-flash-preview still 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-flash is the current stable, if you would rather a demo not pin a preview.

  • createdAt comes 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 globalThis emulator sentinel is needed. Both connectFirestoreEmulator and connectAuthEmulator document themselves as no-ops when the configuration matches, for exactly the repeated-call case the sentinels in firebase.ts and session.ts are guarding against.

  • The server-side read goes through the realtime SDK, so a one-shot query opens a watch stream. fetchRecipes runs in a server component, but it uses the same firebase/firestore instance the browser uses.

    • Running the same query against the same emulator, the full SDK issues RPC Listen opened as a stream, while firebase/firestore/lite issues RunQuery. The lite build requires no gRPC module at all, where the full one pulls @grpc/grpc-js and @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.ts handing a lite instance to the server read and the full one to the live listener, and that file is where the ReactFire swap lands.
  • 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.md and CLAUDE.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 dev only, never next build, so "on every run" is broader than what happens.
    • agentRules: false in next.config.ts disables it outright, which makes keeping the files a choice rather than something forced on the repo.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants