
Sinergi Group: A Bilingual Company Site, Server-Rendered on Purpose
- Ibrahim Fiqhan
- Website
- July 20, 2026
Table of Contents
Sinergi Group is a construction and fabrication company in East Java, trading as two entities: CV. Sinergi Utama and PT. Sembada Sinergi Karya. They needed a public website that a prospective client could find on Google, in Indonesian or English.
They also needed an internal system for running construction projects, which I’ve written up separately.
Both live in one Laravel codebase. They are rendered in completely different ways, and that was the deliberate part.
Two rendering strategies, one application
The internal app is Inertia and React. Client-rendered, behind a login, and that’s fine because search engines are never going to see it.
The public pages are pure Blade, rendered on the server. No React, no hydration, no JavaScript needed to read a word of the page.
That split wasn’t a preference so much as a constraint I agreed with. The client did not want a Node process running on their server, which rules out Inertia’s SSR mode. So server-side rendering had to be PHP, and if it’s PHP, Blade is the answer rather than something clever.
I disabled Inertia SSR in config rather than leaving it available, and left a note in the project’s own guidance file explaining why, because “let’s just turn on SSR” is exactly the suggestion a future contributor would make in good faith.
One translation source feeding two stacks
The site runs in Indonesian and English, and the copy is shared with the React side of the app. Keeping two translation systems in sync was not going to happen.
So there’s one source: nested lang/en.json and lang/id.json, addressed by dot notation.
React reads it through a useTranslation hook. Blade reads it through a small t() helper I wrote and registered via Composer’s autoload files.
That helper exists for a slightly annoying reason. Laravel’s built-in __() doesn’t read nested JSON — it treats the whole dotted string as a flat key and returns it unchanged. You either flatten every translation file, or write four lines of helper. I wrote the helper.
Locale comes from a ?locale=id|en query parameter, applied by middleware and persisted in the session.
The SEO work
Every public page declares its own title, meta_description, meta_keywords, and canonical, and the shared layout assembles those into meta tags, Open Graph tags, and JSON-LD.
There’s a sitemap.xml route generated from real content rather than maintained by hand, because hand-maintained sitemaps go stale in about a month.
One small thing I fixed that had been quietly wrong: the layout appends - Sinergi Group to every title, and several pages were also including it themselves. Every title read “Something - Sinergi Group - Sinergi Group”. Easy to miss, and it’s the part of the page Google shows.
Matching a React design system in Blade
The public pages had to look identical to the app’s shadcn-based components, but Blade has no access to cva variants or tailwind-merge.
So the Tailwind classes in the Blade templates are hand-flattened from the React components: base plus variant plus size, with conflicts resolved the way tailwind-merge would resolve them. Tedious, and it works.
Icons are inline SVG through a Blade component wrapping the Lucide set, so the public pages ship no icon font and no icon JavaScript.
Forms are ordinary POSTs. @csrf, old() repopulation, $errors display, redirect back with a flash message. The contact form queues an email rather than sending it in the request.
Tested where it matters
The public pages have Pest feature tests covering the things that would actually hurt: that titles render server-side, that ?locale=id switches the content, that portfolio tag filtering works, and that a failed validation returns the user’s input rather than an empty form.
Those tests exist because server-rendered SEO output is the kind of thing that breaks silently. Nobody notices a missing meta description until traffic drops.
Deployment
GitHub Actions on push, with development and main mapped to the two environments. Assets are built in CI, public/build/ is rsynced across, then composer install --no-dev, migrate --force, and optimize.
The server only needs PHP. There is no Node daemon, which was the original constraint, and the deploy runs queue:restart so a worker already in flight finishes its current job rather than carrying on against new code.
What I’d take to the next project
The two-rendering-strategies split is worth reusing. Public pages get server-rendered HTML because discoverability is their whole job; the authenticated app gets a rich client because nothing is crawling it anyway.
Framework defaults push you toward picking one and applying it everywhere. There’s no rule that says you have to.
Visit the site or get in touch if you want something similar built.

Ready to build your next project with me?
I’m ready to help you build, improve, and launch your next project — just drop a message and let’s get started.
Get Started Now




