Overview
Shohraty is a digital marketing agency in Algiers. Over six months I built the platform it runs on: a public trilingual website, an admin backoffice its staff operate daily, and the API and database underneath both. Three repositories, 513 commits, 58,000 lines. It went live on the client's own VPS at version 1.0.0 and serves its Arabic homepage in about a second.
Problem
The agency was quoting bespoke work through a static site and collecting enquiries by phone. Every new service meant a developer; every change to what they asked prospects meant a developer. They needed to own their content, their service catalogue, their intake forms, their careers listings and their store — in Arabic first, with French and English alongside — without one.
Context
Two constraints shaped everything. The audience is Algerian, so Arabic is the default and right-to-left is the base case, not a variant. And the client wanted the system on infrastructure they control, which ruled out the managed-platform defaults and made deployment, TLS, static file serving and backups part of the job rather than someone else's problem.
My Role
Sole engineer. I designed the data model, wrote the API, built the backoffice, built the public site, and deployed and hardened all of it. A second contributor helped on frontend components; the architecture, the schema and the delivery were mine.
Goals
- Let non-technical staff change every piece of public content without a deployment
- Ship Arabic, French and English from one content tree, with Arabic as the default
- Replace phone intake with structured, per-service request forms the agency can edit
- Keep the whole system on infrastructure the client owns
- Comply with Algerian data protection law rather than bolting on a generic cookie banner
Technical Decisions
- Two API surfaces over one Prisma schema — /api/public/* open and throttled, /api/admin/* behind a JWT cookie plus CSRF — so public reads can never reach a mutation path
- Service intake forms are data, not code: an admin defines steps and fields in three languages and the backend stores the result as an immutable ServiceFormVersion
- Versioning is triggered by column shape, not by any edit. Renaming a label or reordering fields updates in place; changing a field's key, type or options creates v2. Otherwise every wording fix would orphan the existing submissions
- Each submission stores the formVersionId it answered, so an old request stays readable against the schema it was actually filled against
- Google Sheets sync is asynchronous with per-row status, attempt counts and a retry schedule, because the agency lives in spreadsheets and a failed export must be visible and recoverable rather than silent
- A honeypot field plus a five-per-hour rate limit instead of a captcha — the audience is on mobile data and a captcha costs more conversions than the spam does
- next-intl with the locale as a route segment, and direction set once at the layout root, so no component decides its own direction
Architecture
The Next.js public site reads from the open API surface; the Refine backoffice writes through the protected one. Both hit the same NestJS application — 21 modules, 147 endpoints — over a single Prisma schema of 30 models. The form builder in the backoffice writes immutable schema versions; the public wizard renders whichever version is current; each submission is stamped with the version it answered and then mirrored, by a retrying background job, into a Google Sheets tab named for that service and version.
System map
3 repos / 1 schemaCase Study Screenshots
7 viewsKey Features
- Admin-authored multi-step service request forms, trilingual, rendered from a frozen schema
- Per-row Google Sheets sync with retry, attempt counts and a manual re-sync
- Full CMS coverage: home sections, services, blog, careers, FAQ, contact, store, orders and settings
- Cookie consent gated on Algerian Law 18-07 with per-category opt-in through GTM
- Algerian commerce specifics: DZD, wilaya and commune data, local phone validation, CCP and BaridiMob
Challenges
- Arabic content with embedded Latin brand names rendered with the punctuation on the wrong side. The fix was to stop forcing dir=rtl on nodes holding dynamic content and use dir=auto on prose containers instead, letting the browser resolve direction per string
- The first version of the service detail page mapped description blocks positionally into three fixed slots, so an admin adding a fourth block silently lost it. Replaced with an ordered list of blocks rendered in full
- Cover uploads on create were racing the parent record: the upload fired before the service or post existed, orphaning a media row, then fired again with the parent id. Deferring the upload until after the parent exists removed both the duplicate and the orphan
- Language fallback returned an empty array rather than any available translation when the requested locale was missing, so a partly-translated record looked broken instead of degraded
- Deploying to a client-owned VPS meant the image optimiser, static uploads and TLS all had to be configured rather than assumed, and a wrong remote pattern turns into 400s on every image instead of a silent fallback
Results / Outcomes
- Live at shohraty.dz on the client's own VPS at version 1.0.0, last shipped September 2026
- Measured from the public site: 87ms to first byte, 568ms first contentful paint, 1.02s largest contentful paint, 63KB transferred across 57 requests
- 147 endpoints across 21 modules, 30 Prisma models, 48 test files
- Roughly 650 translated strings per locale across Arabic, French and English
- Every public page — content, services, forms, careers, FAQ, store — is now editable by agency staff without a deployment
What I Learned
- Treating a form as data rather than code moves the hard problem from the frontend to schema evolution, which is the right place for it — but only if versioning is triggered by shape rather than by any edit, or the versions multiply until they are meaningless
- RTL is not a stylesheet. It is a default that has to hold through every breakpoint, every dynamic string and every component that thinks it knows which way text runs
- A background sync is only trustworthy if its failures are visible. Per-row status and an attempt count turned an invisible integration into one the client can operate
- Owning the infrastructure moves work rather than removing it. The performance is better than a managed deployment would have given at this price, and the cost is that every piece of the serving path is now mine to configure
Future Improvements
- Move uploads off local disk so the media pipeline survives a rebuild of the box
- Backend language fallback: return any available translation when the requested locale is missing rather than an empty result
- Fix the bare https root, which intermittently stalls before resolving while the locale routes answer immediately
- Automated visual checks on the Arabic and English trees, since direction regressions are easy to ship and hard to notice