Amir TabatabaeiWork with me
← All work

Client engagement · Sole Developer · Jul 2026 — Jul 2026

ЯB Wrap

Persian RTL site, built to be found from inside Iran

Shipped & running

Next.jsTypeScriptGSAPTailwindJSON-LD

What mattered

One services constant renders both the page copy and the JSON-LD, so the two cannot disagree.

System map

RENDERMAPEXPORTEMBEDHTTPSROBOTSServices constantPage copyJSON-LDStatic HTMLVisitorSearch + AI crawlers

Context

A one-page site for a paint-protection and vinyl-wrap studio in Isfahan. Next.js 14 App Router with output: "export" — a static bundle, no server, no runtime to keep alive for a shop that does not need one.

The constraint that shaped it is not visual: the client cannot claim their Google Business listing, because sanctions block the verification step. Every local-SEO playbook starts there, so the site has to assert what a claimed listing normally would.

One source of truth for content and structured data

Hand-written JSON-LD in the head and hand-written copy on the page drift immediately: someone adds a service and forgets the schema.

Both are generated from the same constant. SERVICES in src/lib/constants.ts renders the visible cards, and the AutoRepair node's offer catalogue is a map over the same array:

hasOfferCatalog: {
  "@type": "OfferCatalog",
  itemListElement: SERVICES.map((s) => ({
    "@type": "Offer",
    itemOffered: { "@type": "Service", name: s.name, description: s.longDesc },
  })),
}

A service cannot exist on the page and be missing from the machine-readable record. Adding one is a single object in one file.

The same SITE constant supplies the address, geo coordinates and both contact points to the schema, the footer and the map link — so the name/address/phone triple that local search cares about is byte-identical everywhere by construction, not by discipline.

Crawler surface as a deliberate output

robots.txt names and allows GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot and PerplexityBot alongside the search engines, and the site ships an llms.txt — a bilingual FA/EN summary written for model consumption rather than for a reader. If the answer to "best PPF shop in Isfahan" is increasingly assembled by an assistant, the site should be readable by one.

RTL, and animation that does not fight it

Persian, dir="rtl", Vazirmatn. Scroll reveals go through one hook — useScrollReveal — that wraps GSAP in a gsap.context() and returns ctx.revert() on unmount, so timelines and ScrollTriggers are torn down rather than accumulating across React's re-mounts. Direction-aware offsets are parameters (x, y) rather than hardcoded, because a slide-in from the left is backwards in RTL. The hero is one GSAP timeline with negative offsets to overlap its steps, instead of nested delays that have to be recomputed whenever a step changes.

Deleting a feature

Two commits on the same day: replace the gallery grid with an Instagram link, then delete the images and the PHP lister behind it.

The shop posts finished cars to Instagram daily and was never going to maintain a second copy. Gallery.tsx is now 39 lines — a heading and one outbound link. Less code, no asset pipeline, and nothing that goes stale on its own.

Result

Shipped and running. The ranking work went live in July 2026, which is too recent to quote numbers for — there is no before/after here yet.

Screens

The site's opening screen, right-to-left Persian: a headline over a dark workshop photograph, counters for years in business, completed projects and followers, and two calls to action
Six service cards over photographs of wrapped cars, each with a Persian and English name, a short description and a qualifier such as full-body or partial
Ten collapsed questions in two columns, covering film durability, installation time, pricing and removal
Four numbered steps running right to left: consultation, inspection and preparation, installation, handover