DEV Community

Cover image for Why I Built a Multilingual SaaS Frontend Without React, Next.js, or a Build Step
Charles Snow
Charles Snow

Posted on

Why I Built a Multilingual SaaS Frontend Without React, Next.js, or a Build Step

This frontend ships 40+ conversion tools in 7 languages, handles auth, Stripe payments, and trial gating — and the build command is literally echo 'Static site - no build needed'.

For FastlyConvert, I skipped React, Next.js, and every bundler on the market. The entire frontend is plain HTML files, vanilla JS modules, Tailwind via CDN, and Vercel rewrites.

That sounds like a nostalgic choice, but it was mostly a practical one: the product is a tool-heavy, SEO-sensitive SaaS with lots of landing pages, lots of long-tail intent, and a real need for predictable HTML output.

This post is not an anti-framework manifesto. It is a case study in what you gain, what you lose, and where plain HTML plus edge logic still works surprisingly well.

The architecture in one sentence

Each tool page is a standalone HTML document, shared behavior lives in a handful of global JS modules, and Vercel handles clean URLs plus language-aware routing at the edge.

That means pages like pdf-to-word, meeting-transcription, and text-to-speech can be shipped as normal HTML files while still behaving like a modern SaaS frontend with auth, pricing, trial gating, localization, and structured SEO.

Here is the stack at a glance:

Layer Tool Role
Pages Standalone HTML Each tool owns its markup, schema, and SEO
Styling Tailwind CDN No build step, no PostCSS
Logic Vanilla JS (IIFE modules) Auth, payments, i18n, trial gating
Routing vercel.json rewrites Clean URLs mapped to real files
SEO Edge Middleware Patches canonical, title, description per language
i18n Custom window.i18n 7 languages, attribute-based DOM hydration

Why I did not use a framework

The main reason was control.

I wanted:

  • fully rendered HTML for every public page
  • clean URL routing without a client router
  • predictable metadata for search engines and social previews
  • the ability to ship many conversion pages without a JS-heavy runtime
  • simple deployment on Vercel without a frontend build artifact

This repo leans hard into that tradeoff. package.json has a build script that literally returns Static site - no build needed, while the actual product surface lives in index.html, pages/**/*.html, assets/js/*.js, and vercel.json.

Clean URLs without a frontend router

Instead of a SPA router, the project uses Vercel rewrites and redirects to map public URLs to real HTML files.

This is one of the most important parts of the stack, because it lets the site keep human-friendly paths like /pdf-to-word while the actual file lives under pages/pdf/pdf-to-word.html.

Here is the pattern in vercel.json:

{
  "rewrites": [
    { "source": "/:lang(fr|ja|es|pt|zh-CN|zh-TW)/:path*", "destination": "/:path*?lang=:lang" },
    { "source": "/pdf-to-word", "destination": "/pages/pdf/pdf-to-word.html" },
    { "source": "/meeting-transcription", "destination": "/pages/audio/meeting-transcription.html" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

That one choice removes a lot of application complexity.

There is no client-side route resolution to debug. Crawlers get real HTML per route with no client router, while localized UI text is still applied client-side and the edge layer patches canonical, title, description, and H1 for language-prefixed URLs. Product pages stay individually editable. And if one page needs a weird SEO experiment or custom schema, it can own its own markup.

The downside is obvious too: duplication. When you have a lot of standalone pages, you need discipline around shared scripts, naming, and conventions.

Edge middleware became the SEO control plane

The interesting part is not just the rewrites. It is what happens after the request arrives.

middleware.js does several jobs that a framework would normally hide inside server rendering logic:

  • protects admin routes
  • normalizes ?lang=en away from default-language URLs
  • redirects ?lang=fr to /fr/...
  • patches canonical, <html lang>, <title>, and meta description for /{lang}/... routes
  • updates og:url and adds or replaces og:locale without trying to rewrite every Open Graph field

This is the core idea:

const seoData = SEO_META[pathKey]?.[lang];
if (seoData) {
    html = html.replace(/<title>[^<]*<\/title>/i, `<title>${escapeHtml(seoData.title)}</title>`);
    html = html.replace(
        /<meta\s+name="description"\s+content="[^"]*"\s*\/?>/i,
        `<meta name="description" content="${escapeHtml(seoData.desc)}" />`
    );
}
Enter fullscreen mode Exit fullscreen mode

This worked well for a simple reason: I did not want seven physical copies of every page just to localize meta tags.

The public HTML files stay mostly English-first, while the edge layer adjusts canonical URLs, titles, descriptions, og:url, and og:locale for localized traffic. That is a pretty efficient middle ground between a purely static site and a full SSR app.

A custom i18n system was enough

The localization setup is also deliberately simple.

Two shared JS files provide translation dictionaries and expose a global window.i18n. The pages use HTML attributes for text binding, so the markup stays clean while the language layer remains centralized.

In practice it looks like this:

<!-- HTML stays clean and readable -->
<button data-i18n="converter.startConversion">Start Conversion</button>
<p data-i18n-html="converter.filesUpTo">Files up to {size}</p>
Enter fullscreen mode Exit fullscreen mode
// Client-side hydration on page load
window.i18n.t('converter.startConversion')
// → "Start Conversion" (en)
// → "開始轉換" (zh-TW)
// → "Iniciar conversión" (es)
Enter fullscreen mode Exit fullscreen mode

The extended dictionary covers seven languages: en, fr, zh-CN, zh-TW, ja, es, pt.

That setup would feel primitive in a large component-driven app, but in a static multi-page product it is surprisingly effective. The translation source is big, but the behavior is straightforward and easy to inspect.

There is also an SEO upside: localized UI text and localized metadata are handled separately. UI strings come from the client-side i18n layer, while search-facing metadata is patched at the edge. That separation makes the intent of each layer much clearer.

Shared business logic still matters, even on a static site

A static frontend does not mean a dumb frontend.

This repo still has to handle authentication, pricing, trial limits, user state, and conversion-specific UX. The difference is that those concerns are organized as plain browser scripts instead of framework components.

Some examples:

  • An auth modal module (~1200 lines) handles login/register state, Google OAuth callbacks, JWT token expiry checks, referral code capture, and CSRF-protected requests.
  • A payment module (~1100 lines) manages Stripe checkout sessions, subscription tiers, file-size limits (100MB free / 500MB pro), and toast notifications.
  • A conversion helper (~170 lines) detects the current tool type from the URL path and coordinates trial-limit gating with the upgrade modal.

The conversion gating flow is especially pragmatic:

function handleLockedResponse(data) {
    if (!data || !data.locked) {
        return false;
    }

    if (window.TrialUI && window.TrialUI.showUpgradeModal) {
        window.TrialUI.showUpgradeModal(CONVERSION_TYPE);
    }

    return true;
}
Enter fullscreen mode Exit fullscreen mode

That is not glamorous code, but it is honest product code. A user hits a limit, the UI reacts, and the behavior stays shared across many tool pages.

What I like about this approach

After living with this architecture, a few benefits stand out.

1. The HTML is predictable

For pages that need to rank, share well, and render fast, predictability matters. There is very little mystery about what ships to the browser.

2. Page-level ownership is easy

If a page needs a different FAQ schema, hero layout, or copy structure, I can edit that page directly without fighting a component abstraction.

3. Performance stays naturally restrained

When you do not start with a framework runtime, it is much harder to accidentally build an over-engineered page.

4. Deployment is simple

This project fits static hosting well. Vercel handles routing and headers, while the site itself remains mostly plain files.

What I do not like about this approach

This stack absolutely has costs.

1. Duplication is real

With many standalone HTML pages, repeated markup is unavoidable. Shared modules help, but there is still a maintenance tax.

2. Type safety is basically social, not technical

You do not get compiler help for naming drift or loosely coupled globals. You need strong conventions or things will rot.

3. Cross-page refactors are more manual

A framework component tree gives you stronger reuse primitives. Here, the reuse model is mostly conventions, helper scripts, and carefully shared JS modules.

4. You need to think harder about boundaries

Because everything is plain JS, it is easier to let modules grow too large. Some of these modules carry more responsibility than they probably should.

Where I think this model still works in 2026

I would not choose this architecture for every product.

I would use it when most of these are true:

  • the site is content- or tool-heavy
  • each page benefits from owning its own HTML and metadata
  • SEO matters more than frontend novelty
  • product interactions are meaningful but not app-like enough to justify a large client framework
  • the team values directness over abstraction

That describes a lot of utility SaaS products, especially conversion tools, calculators, documentation-heavy products, and content-backed acquisition sites.

If you want a concrete example of the result, here is the tools index: https://www.fastlyconvert.com/tools

Final thought

I do not think frameworks are the problem. Defaulting to them without asking what the product actually needs is the problem.

For this codebase, plain HTML plus shared JS plus edge middleware turned out to be a very good fit. It gave me fine-grained SEO control, straightforward deployment, and enough flexibility to support auth, payments, localization, and dozens of conversion pages without shipping a heavyweight frontend runtime.

Sometimes the most maintainable stack is not the most fashionable one. It is the one whose tradeoffs are obvious from the first read of the codebase.


Have you shipped anything production-grade without a framework recently? Or tried edge middleware for SEO? I am curious what tradeoffs you ran into — drop a comment.

Top comments (0)