Back to project

Shohraty

How a marketing agency's website ended up needing immutable schema versioning, and what RTL-first actually costs.

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 schema
Public siteNext 16 · AR/FR/EN · RTL16k LOC · 234 commitsBackofficeRefine · TipTap · Vite18k LOC · 145 commits/api/public/*open · throttled/api/admin/*JWT cookie + CSRFNestJS 1121 modules147 endpoints48 test filesPostgreSQLPrisma 7 · 30 modelsServiceFormVersionimmutable schemasteps → fields · AR/FR/ENSheetsone tab perservice-vNreadswritesrenders 8-step wizardretryDEPLOYEDshohraty.dz · client VPS · nginx · v1.0.0MEASURED 4 SEP 2026TTFB 87ms · FCP 568ms · LCP 1.02s · 63KB
Two client apps, two API surfaces, one schema. The violet path is the part worth asking about: an admin authors a form, the backend freezes it as an immutable version, the public wizard renders that version, and every submission is stamped with the version it answered before a retrying job mirrors it into a per-version spreadsheet tab.

Case Study Screenshots

7 views
The live Arabic homepage. Arabic is the default locale and the whole layout is right-to-left, not a mirrored afterthought.
The same page in English. Direction, navigation order and card sequence all flip from one content tree — the pair is the clearest proof the RTL work is real.
Step 3 of 8 in the service request wizard. None of these fields is in the frontend code: an admin authored them in three languages and the backend froze that definition as an immutable form version.
The wizard renders whatever the current schema version defines, including how many steps there are. The progress indicator is generated, not hard-coded.
Services are CMS records, not routes. Each one carries its own translations, FAQ, workflow phases and request form.
Consent is gated on Algerian Law 18-07, with per-category opt-in. Analytics and ad pixels only fire through GTM once the matching category is accepted.
The Arabic homepage at 390px. RTL has to survive every breakpoint, not just the desktop one.

Key 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