This site
This portfolio, built in React on Firebase. Visitors send a structured inquiry and can watch its status; I answer from a private inbox that updates live.
- Type
- Own project
- Role
- Design, build, deploy
- When
- September 2026
- Built with
- React 19, React Router 8, Firebase Auth, Cloud Firestore, TypeScript, Vite, Vitest
- Status
- Live

The job
I’m applying for React and Firebase roles, and I take on small projects for businesses. A portfolio for both has to prove the stack it talks about, so this site is built on it: React for every page, Firebase for everything that has to remember something.
Two groups read it. Hiring teams want to see how I work: the structure, the tests, the decisions. Business owners want to know whether I can build their site, and how to reach me. The site is in English, German and Romanian, and it runs on GitHub Pages, which serves files and nothing else. So there is no server of my own: the security rules are the backend.
What I built
The design came first, in Claude Design: three directions, then every page for desktop and phone, before any code. The direction that won, “Timetable”, uses condensed type, a work list that reads like a train timetable, and one yellow highlighter. The code was written with Claude Code. I review what ships, and the checks below run on every change.
- Every public page prerendered to HTML at build time with React Router 8: 30 pages, ten in each language, each with hreflang links and its own Content Security Policy.
- An inquiry form that changes with what you ask about (a project, a job, something else), checks every field as you type, and stores the inquiry in Cloud Firestore.
- A status page that updates live when I open the inquiry and when I mark it answered.
- A private inbox at /admin: Google sign-in, owner only, a live list with filters, statuses, private notes and delete.
- Security rules with 29 tests against the Firestore emulator, 65 unit tests, and an end-to-end test in Chrome against the Auth and Firestore emulators. CI runs all three on every push.
- Your browserThe form checks each field as you type. Firebase loads only when you press Send.
- Anonymous sign-inGives this browser an ID so it can follow its own inquiry. You are not asked for anything.
- One batched writeThe inquiry and a note of when this browser last sent one. Both are saved, or neither.
- Firestore security rulesRefuse unknown fields, wrong types, text that is too long, and a second send from the same browser within a minute.
- Firestore, in the EUEach inquiry carries a deletion date a year ahead. My inbox deletes it once that date has passed.
- Your status page Changes on its own when I read and answer your inquiry.My inbox Google sign-in, owner only. New inquiries appear without a reload.

The hard parts
No server, and the form still has to be safe
Anyone can call Firestore directly with the site’s public config, skipping my form. So the form’s checks are only for people; the rules are the check that counts.
A new inquiry must have exactly the expected fields, each of the right type and length, a valid e-mail address, the status “new”, the sender’s own user ID, and a creation time equal to the server’s clock. The owner may later change three fields and nothing else. The form and the rules share their limits: the rules tests import the same LIMITS object as the form, and test each limit and one character past it, so the two cannot drift apart.
// firestore.rules
function isValidNewInquiry(data) {
return data.keys().hasAll(inquiryFields())
&& data.keys().hasOnly(inquiryFields())
&& data.kind in ["project", "job", "other"]
&& isText(data.name, 1, 100)
&& isText(data.message, 20, 2000)
// …
&& data.uid == request.auth.uid
&& data.createdAt == request.time
&& expiresInAYear(data.expireAt);
}
One inquiry a minute, without a server
A form without a rate limit invites a script that sends a thousand messages. Without a server, there is nowhere to count them. Firestore can count them, though.
When you press Send, Firebase signs your browser in anonymously, so it has an ID. The inquiry is written in one batch with a second document, senders/your-ID, that holds the time of your last inquiry. The rule for the inquiry uses getAfter() to check that this batch also sets that time to now. The rule for the senders document refuses the update if the previous time is less than 60 seconds ago. Both documents are saved, or neither.
// firestore.rules
match /senders/{uid} {
allow update: if request.auth != null
&& request.auth.uid == uid
&& request.resource.data.keys().hasOnly(["lastSentAt"])
&& request.resource.data.lastSentAt == request.time
&& request.time > resource.data.lastSentAt + duration.value(60, "s");
}
This stops a careless loop, not a determined attacker, who can create new anonymous users. App Check would be the next layer.

Firebase only when you need it
The Firebase SDK is the largest dependency on the site, and most visitors never send an inquiry. So no page imports it at the top. The form loads the Firestore and Auth code with a dynamic import() when you press Send, and the status page when it opens.
The home page ships about 130 KB of JavaScript, compressed, and no Firebase code at all. Pressing Send adds about 155 KB. The inbox, which needs Google sign-in, is a separate route that visitors never load. Fonts are self-hosted, and only their Latin subsets, which cover English, German and Romanian.
Data with an expiry date
The privacy notice promises that an inquiry is deleted after 12 months, and a promise like that is easy to forget. So every inquiry carries an expireAt date a year ahead, and the rules refuse any other date. The client sets it from its own clock, so the rule accepts it within a day either way.
Firestore’s time-to-live policy would delete such documents by itself, but it needs the paid Blaze plan, and this site runs on the free one. So the inbox does it: each time I open it, it queries every inquiry past its date and deletes it together with my note on it, in one batch. My notes copy their inquiry’s expireAt, and the rules check that they match, so a note cannot outlive its inquiry.
Results
- 29 security-rule tests pass against the Firestore emulator, and 65 unit tests pass (27 September 2026).
- The end-to-end test passes all 17 checks: case studies opening from the work list in three languages, sending, validation, the rate limit, a stranger’s browser refused, the owner’s sign-in, and the status page changing live (27 September 2026).
- 30 pages prerendered in three languages, with no horizontal scroll at 375 px and no console errors.
- Home page: about 130 KB of JavaScript, compressed, with no Firebase code.
What I would do next
- Add App Check with reCAPTCHA Enterprise, so only this site can write to Firestore.
- On the Blaze plan: let Firestore’s time-to-live policy delete old inquiries, and send me an e-mail for each new one from a Cloud Function.
- Send inquiries with Firestore Lite, which is much smaller, and keep the full SDK only for the pages that update live.