Sitemap
JP Sullivan

JP Sullivan

web3

How I Built a Bilingual B2B Catalog With a 3D Pipe Simulator

8 min readOct 3, 2026

--

React 19 server rendering, a real right-to-left layout, crawlable filters and a Three.js viewer — for a polyethylene pipe supplier

Press enter or click to view image in full size

Most industrial websites are a brochure with a phone number. The buyers who visit them are engineers and contractors: they arrive with a diameter, a pressure class and a quantity in mind, and they leave if they can’t find a size table in ten seconds.

I recently shipped cszaal.com, a bilingual catalog for Caspian Sanat ZAAL, a supplier of polyethylene pipes, fittings, valves and welding machines. The brief was “put our catalog online.” The real job turned out to be four problems at once:

  1. Make a technical product range easy to search: by outside diameter, SDR, pressure class and grade.
  2. Make it easy for Google to find, page by page and filter by filter.
  3. Do it in two languages that read in opposite directions: English (left-to-right) and Persian (right-to-left).
  4. Never show a number that isn’t true. For an engineering buyer, a wrong pipe spec is worse than a missing one.

This is how I approached each one, and what I would do the same way next time.

1. Every page is the real app, rendered at build time

The site is a React 19 + TypeScript app with an Express server. A single-page app is pleasant to use, but if search engines receive an empty <div id="root">, the whole catalog is invisible until JavaScript runs.

So the build renders the actual application for every route. A prerender script gives each URL its own jsdom environment (the right URL, empty storage, that page’s product data), calls the server entry of the app, and writes the result into the HTML file. In the browser, hydrateRoot picks up that markup instead of rendering from scratch.

The result: more than 580 pages (English and Persian) that each ship a full title, one H1, real body text, hreflang links and JSON-LD structured data before any JavaScript runs.

Server rendering only works if the server and the browser produce the same first render. Three rules kept hydration exact:

  • The first render may not read anything the server can’t know: no localStorage, no window size, no clock. Those values are read in useEffect, after hydration. (The inquiry list, stored in localStorage, is the classic example.)
  • The component tree in the server entry must equal the client entry, provider for provider.
  • Lazy-loaded pages must finish loading before hydration. My first version hydrated while a page’s code chunk was still downloading. Any early re-render swapped the server HTML for a loading spinner: a visible flash and a layout shift (CLS) of 0.69 on category pages. Preloading the current route’s chunk before calling hydrateRoot brought every measured page to CLS 0.000.

I verify hydration with a headless browser and treat React’s hydration errors (#418, #423, #425) as build failures.

2. Right-to-left is not a CSS flip

Press enter or click to view image in full size
One codebase, two directions: the English and Persian home pages.

Setting dir="rtl" gets you about 70% of the way. The rest is where bugs hide:

  • Tailwind’s directional utilities are physical. left-4, pl-6, bg-gradient-to-l and -translate-x-* don't flip with the document direction. Every one of them has to branch on the language, or use logical properties.
  • Mixed labels reverse themselves. Inside Persian text, a label like 110 mm or PN 16 renders as mm 110 unless the element resolves its own direction. Putting dir="auto" on the element that holds such a label fixes it.
  • Persian text uses the zero-width non-joiner (U+200C) inside words. Any script that searches or compares Persian text has to treat it as significant. Several tools (PowerShell 5.1’s default text reading, for one) silently mangle UTF-8 Persian unless you force the encoding.
  • Fonts are self-hosted (Vazirmatn for Persian, Inter for Latin) and preloaded per language, which removed a small re-wrap of text after the fonts arrived.

Every label in the UI exists in both languages, and the approved content files always carry _fa / _en pairs, so a page can't ship half-translated.

3. Filters that Google can crawl

Press enter or click to view image in full size
Each single filter is its own URL, linked from the category page.

Catalog filters are usually JavaScript state, which means Google never sees “PE pipe, 110 mm” as a page. Here, each single-filter view is a real URL (for example ?od_mm=110). That gives 205 filter pages per language, all listed in the sitemap and all linked from their category page as ordinary <a href> chips. A sitemap URL that nothing links to is weak; a linked one gets crawled.

Combinations of several filters stay useful for visitors but are not linked and canonicalise to the base category, so the site doesn’t generate thousands of thin, near-duplicate pages.

The same idea drives the whole interface: anything that navigates is an <a href>, never a <button onClick>, because crawlers follow links, not click handlers. A small helper keeps plain clicks inside the single-page app while Ctrl/Cmd/middle-click still open a new tab.

Press enter or click to view image in full size
Quick view: pick a size and specification without leaving the page.

Product cards open a quick view dialog (key facts, a size → specification picker, “add to inquiry list”). Each card is still a real link to the product page: a plain left click opens the dialog, any modified click behaves like a normal link, and the dialog never appears in the prerendered HTML and never changes the URL. Visitors get speed, crawlers get the link.

4. A 3D pipe simulator that doesn’t hurt performance

Press enter or click to view image in full size
The simulator: every published size, drawn to scale.

The most visible feature is the pipe simulator. Pick a product and a size, and it draws the pipe to scale: outside diameter, bore and wall thickness. Change the pressure class and you can watch the wall get thinner.

Get JP Sullivan’s stories in your inbox

Join Medium for free to get updates from this writer.

Three.js is heavy, so it is isolated in one component and loaded only after hydration, only on that page. The server and the first paint show a 2D SVG drawing; if the device has no WebGL, the 2D view simply stays. A test checks that the main bundle never contains Three.js.

Press enter or click to view image in full size
Every value is labelled with where it comes from.

What makes it useful to engineers is that every value says where it comes from:

  • From the size table: outside diameter, wall thickness, SDR, pressure class, weight per metre.
  • From the catalogue: grade and standards.
  • Calculated, with the formula shown next to it: inside diameter (ID = OD − 2e), bore area, litres of water per metre, coil or bar weight, and mean flow velocity (v = Q ÷ (π/4 × ID²)).

Nothing is drawn to scale unless its dimensions are published. Corrugated profiles and fitting bodies, for which the supplier hasn’t published full geometry, are clearly labelled as schematics.

Press enter or click to view image in full size
Cross-section view.
Press enter or click to view image in full size
Keep a configuration and compare it with another.

A cross-section view, a flow-velocity calculator and a side-by-side comparison complete it. Any configuration can be shared with a ?sku= link; it's applied after hydration, and the canonical URL stays the base page.

Press enter or click to view image in full size
Every calculation is explained on the page in plain language.

5. Data integrity, enforced by tests

This was the rule I cared about most: no number, spec, price, standard or company claim appears on the site unless it traces to the catalog, a supplier spreadsheet or an approved content file. Missing data becomes a line in a “content gaps” document, never a plausible-looking guess.

A few consequences:

  • No prices anywhere. Every size row carries a “price is demo” flag, and the interface always says “request a quotation.” Prices change daily in this market; a stale price is a liability.
  • Supplier, not manufacturer. The company supplies; it has no factory and holds no certificates of its own. The structured data names it as the product’s brand, never its manufacturer.
  • A claims scanner. A test scans both the source and the built HTML for about twenty banned patterns: invented certificates, “our factory”, response-time promises, links to messenger accounts the company doesn’t own.
  • Right number, right product. A number can exist in a source file and still be wrong if it’s attached to the wrong product. Size tables are built per product from that product’s own rows.

Over 860 automated tests guard these rules, the hydration rules above, SEO metadata, internal links, security headers and mobile layout.

6. Fast on a phone

At launch, PageSpeed Insights (mobile) scored 99 for performance and 100 for SEO and best practices, and every measured page had a layout shift of 0.000.

The changes that mattered most:

  • A portrait crop of the home hero image for phones. Phones were downloading a 1280-pixel landscape image that competed with the script and fonts. A media-scoped <source> with a 540/810-pixel portrait crop, preloaded with the identical srcset, brought the home page's largest contentful paint from about 3.0 s to 2.2 s on a throttled phone profile.
  • Brotli and gzip compression, immutable caching for hashed assets, and no-cache for HTML so deployments show up immediately.
  • Self-hosted fonts and images. No page loads anything from another host.
  • No animation that hides content until JavaScript runs. Above-the-fold elements render visible in the server HTML.

7. From inquiry to the sales team

Visitors build an inquiry list (product, size, specification, quantity) and send it. The server turns it into a message for the sales team and a Persian PDF generated on the server. Right-to-left text in a PDF needs a bidirectional-text algorithm (bidi-js) on top of pdfkit, plus an embedded Persian font.

Both outputs come from one data model, so the message and the PDF can never disagree, and a missing quantity stays “unknown” instead of quietly becoming “1”. Notifications are sent after the response, so the customer never waits for them. A small spam guard rejects submissions that are too fast, contain links, or exceed a daily limit per phone number.

What I’d keep for the next project

  • Prerender the real app, and write the hydration rules down before writing components.
  • Treat RTL as a design requirement from day one, not a stylesheet at the end.
  • Make every filter you want ranked a linked URL, and canonicalise the rest.
  • Load heavy features (3D, charts) only where they’re used, with a server-rendered fallback.
  • Turn “don’t make things up” into tests. Content rules that only live in people’s heads get broken.

Stack: React 19 · TypeScript · Vite · Tailwind CSS v4 · Express · Three.js · Framer Motion · Vitest · nginx on Ubuntu.

🔗 Live site: cszaal.com

🧊 Try the simulator: cszaal.com/en/simulator

📐 Example category with crawlable filters: PE pressure pipes

📘 Example technical article from the site: SDR and PN pressure class in polyethylene pipe

<iframe width=”560" height=”315" src=”https://www.youtube.com/embed/GzCIY2JxnqI?si=GPMguoG6k7w5Do_w" title=”YouTube video player” frameborder=”0" allow=”accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share” referrerpolicy=”strict-origin-when-cross-origin” allowfullscreen></iframe>

Building a website for an industrial or B2B company that needs to be fast, multilingual and findable? I’d be happy to talk.

--

--