← Portfolio
Case study 01 — Client work

Prompt Plus

Accounting
Solo buildReact · Vite · Firebase2026Bilingual TH / EN

An accounting firm needed a website that its own staff could keep current: seven pages, two languages, articles and news posted weekly. Nobody in the office was going to run a deploy to change a phone number.

So the site reads all of its content from an API, and a second small app — the admin — writes it. The only thing that stays in code is the part that must not drift: the Thai statutory filing calendar, where a wrong due date is a fine rather than a typo.

Project facts

Client
Prompt Plus Accounting, Bangkok
Role
Sole engineer — architecture, both front-ends, backend, security review, deployment
Scope
Bilingual marketing site, admin CMS, statutory filing calendar, automated delivery
Stack
React · Vite · Firebase Hosting · Cloud Functions · Firestore
Status
Live since September 2026 at promptplus-accounting.com
Prompt Plus Accounting home page, Thai
01 Homemain-web
The 2569 statutory filing calendar, showing the current month's deadlines
02 Filing calendarmain-web
The admin content editor, with the live preview beside the fields
03 AdminAdmin app

Three parts

One server, two appsArchitecture
Part 01 — main-web

Public site

Pages have real /th and /en URLs, rendered on the server for crawlers; the admin app uses hash routing. Every string is a {th, en} pair, switchable on every page. Content is fetched at runtime, and if that fetch fails the app falls back to a snapshot bundled at build time — so the site never renders empty, and never renders twice.

Part 02 — admin

Admin app

Posts (articles and news, both languages) and site content. The editing forms are generated from the shape of the content JSON — add a field to content.json and the form picks it up with no admin code change. New posts appear on the site immediately; the home page pulls the three latest.

Part 03 — backend

Content service

Serverless functions in Singapore, backed by a managed document store. The same service renders finished HTML for crawlers, so the marketing pages are readable without running JavaScript. Published content is public by design; everything that changes it sits behind the firm’s own Google accounts.

Reader→edge cache→prerendered page·Crawler→server-rendered HTML·Staff→Google sign-in→admin·Backend down→bundled snapshot

Decisions

Three that matteredAnd why
Decision 01

Regulatory logic stays in code. Copy stays with the client.

Everything editable is editable — except the filing calendar. Due dates for ภ.ง.ด.1/3/53, ภ.พ.30 and สปส.1-10 are monthly rules, with annual forms layered onto specific months. That is logic, not copy, so it lives in one module with the year at the top and is reviewed rather than typed into a CMS field.

/* Thai statutory filing data for the 2569 (2026) calendar.
   Regulatory logic stays in code — page copy is admin-managed content. */
export const MONTHLY = [
  ["ภ.ง.ด.1/3/53", "PND1/3/53", "ภาษีหัก ณ ที่จ่าย", "Withholding tax", …, 7],
  ["ภ.พ.30",       "PP30",      "ภาษีมูลค่าเพิ่ม",   "VAT return",     …, 15],
  ["สปส.1-10",     "SSO 1-10",  "เงินสมทบประกันสังคม", "Social security", …, 15],
];
Decision 02

The admin form is generated from the content, not hand-built.

A hand-written form per page would have meant an admin release every time the firm wanted a new FAQ row or a price line. Instead the editor walks the content JSON and renders a field per leaf, grouped by page. Adding a field on the server is the whole change.

The trade is deliberate: the admin gains a field the moment the backend does, and no admin release is needed to expose it. The cost is that the editor must be defensive about shapes it has never seen — which is where most of its code went.

Decision 03

No deploy key exists to steal.

Releases run from CI, which is the usual place a long-lived cloud credential ends up sitting in a settings page forever. This one authenticates by short-lived federated identity instead, scoped to a single repository — so there is no key file to leak, rotate, or forget. Every release also re-checks the live site afterwards: response headers, crawler visibility, and a frame-by-frame check that the page does not repaint after first render.

Decision 04

A quote wizard instead of a contact form.

Prospective clients ask what bookkeeping will cost before they ask anything else. Three steps — business type, volume, services — return an indicative fee on the page, so the first email arrives already qualified.

7Pages, fully editable
2Languages, every string
0Rebuilds to publish
8API routes total

Closed before launch

Since the first draftHonest list
  • A pre-launch security review closed a host-header SSRF and a stored-XSS path, and added security headers, rate limiting and server-side validation.
  • Deploys are keyless: GitHub Actions authenticates to Google Cloud with Workload Identity Federation, so no cloud key is stored anywhere; tests, secret scanning and a dependency audit run on every release, and the live site is checked after each deploy.
  • The shared admin password is gone: staff sign in with their own Google accounts, checked against an allowlist.
  • Sessions no longer live in server memory — Firebase Authentication holds them, so a restart logs nobody out.
  • The firm's own photography replaced every placeholder, uploaded by staff through the admin.
  • Contact and quote forms save to the database, appear in the admin, and send the office an email alert.
  • Still open: an automatic reply to whoever sent the form — written and tested, waiting on the firm's mail credential.

The static HTML version that preceded this build is still in the repo for reference. Keeping it there was deliberate: it is the fastest way to show the client what changed, and it costs nothing to leave behind.