← All articles
8 min readWeb,Rendering,Performance,Architecture

Static Export on Three Sites: Next.js, Vite and Prerendering

By Mykhailo Boichuk · Chief Technology Officer

In short

BBMM Technologies builds all three of its websites to static files, and none of them renders a page per request. bbmmtech.com and textpack.app use the static export mode of Next.js, and flowoapp.com is a Vite and React app whose pages are prerendered by a script after the bundle is built. The cost is that redirects, headers, image resizing and anything tied to the visitor must be solved at build time or in the browser.

Three rendering strategies and the one we use

Server-side rendering builds a page on each request. Static generation builds it once, during the build. Static export goes one step further and leaves no application server at all, only files.

The choice depends on how often content changes and whether it differs per visitor. Our sites have no accounts and no per-visitor pages, and their content changes when we deploy. Static export fits that case, so all three sites use it.

Static export in Next.js

bbmmtech.com sets output to export in its Next.js configuration, and the build writes the whole site to a folder named out.

Two more settings follow from that choice. trailingSlash is true, so each route is a folder with an index.html inside and any file host can serve it. images.unoptimized is true, because the image optimizer is a server feature.

A dynamic route has to list its pages in advance. Each of the three dynamic routes exports generateStaticParams.

  • The article route returns the slug of every released article.
  • The tag route returns every tag used by at least two articles.
  • The legal route returns the slug of each legal document.

Six more routes produce files, and each is marked force-static: robots.txt, sitemap.xml, the web manifest, llms.txt, llms-full.txt and the RSS feed. Their code runs once during the build and the result is saved as a file.

What static export removes

A static export runs no code at request time, so four server features need a substitute.

  • Redirects. The old /privacy and /terms addresses on bbmmtech.com are small static pages with a meta refresh and a canonical link to the new address.
  • Response headers. A _headers file in the public folder is copied into the output and read by the host. It sets five security headers on every path and a one-year immutable cache on the hashed build assets.
  • Image resizing. textpack.app uses a custom image loader that maps a product screenshot to a pre-sized WebP file. Each screenshot is stored at five widths from 384 to 1920 pixels.
  • The current time. Output that depends on the date is computed when the build runs. Our deploy workflow therefore also runs on a timer twice a day, so that output advances without a push.

The timer was added for a staged release function that compares the build date with a start date. That function is switched off at present and every article is released.

An unknown path is answered by a 404.html file, which the host serves with a 404 status.

Prerendering a Vite and React app

flowoapp.com is a single-page React app built with Vite, and a script turns it into static pages after the bundle is built. The build command runs six steps in order.

  1. 1.Collect journal articles delivered as JSON into one module.
  2. 2.Snapshot the public help centre into a data file.
  3. 3.Run the Vite build.
  4. 4.Bundle the worker for the one dynamic endpoint.
  5. 5.Prerender every page.
  6. 6.Validate the output.

The prerender step first bundles a server entry with Vite. That entry renders the hero, the guide pages, the journal articles and a few utility pages to static markup with the React components the browser uses. The script places each result inside the root element of the built index.html and rewrites the head for that page.

The list of routes written in this step then produces sitemap.xml and llms-full.txt. The validation step fails the build if a listed page lacks a file, a title, an h1 or a single canonical link.

In the browser, React renders over the static markup with createRoot. On the home page it waits until the browser has painted the static hero. On a guide or an article it first loads that page's chunk, so the static page is replaced by the same page and no loading state appears between the two.

Variation per visit without a server

A static page is the same bytes for every visitor, yet parts of the Flowo home page differ per visit. The store buttons and the campaign headline are solved inside the HTML, and language is solved with separate pages.

The prerendered hero contains every variant. It holds the buttons for iOS, for Android and for other platforms, and on the English page it holds every campaign headline. An inline script at the top of the head runs before first paint. It reads the user agent and the campaign parameters, sets attributes on the html element, and adds a style rule that hides the variants that do not apply.

Language is handled on the root address only. When a saved or browser language other than English has its own home page, the script replaces the location with that page before anything renders. The five language home pages are separate prerendered files that link to each other with hreflang.

One path on flowoapp.com is dynamic. A routes file limits the Cloudflare Pages worker to /api/*, which serves the site's chat endpoint, and every page and asset is served as a file. A rewrite rule answers each invite address under /r/ with one prerendered page and a 200 status, and that page reads the code from the address.

How the files are deployed

Both bbmmtech.com and flowoapp.com deploy from a GitHub Actions workflow that builds once and uploads the output folder to Cloudflare Pages.

The flowoapp.com workflow runs lint, the unit tests and the build before anything is uploaded. It packs the output with tar so that dot-directories such as .well-known survive. Before the upload it checks that index.html, 404.html, the headers file, the worker and the routes file are all present. A push to main is a production deploy, and a manual run from another branch is a preview.

The bbmmtech.com workflow sends one build artifact to two static hosts, Cloudflare Pages and GitHub Pages. With both current, a DNS change in either direction is a complete rollback. After a successful deploy, a job submits the sitemap URLs to IndexNow.

Caching follows from the file names. On flowoapp.com, HTML keeps the host default and is revalidated on every request, so a deploy is visible at once. Content-hashed assets are cached for one year as immutable. Media files that keep their names across re-encodes are fresh for one day and may be served stale for a week while they revalidate.

textpack.app is a Next.js export as well. Its CI builds the out folder on every push to main, and its host configuration serves that folder with clean URLs.

When we would add a server

We have built no server-rendered page on any of the three sites. We would add one for content that changes between deploys or for pages that differ per signed-in visitor, and neither exists on these sites today.

Key takeaways

  • bbmmtech.com and textpack.app are Next.js static exports, and flowoapp.com is a Vite and React app prerendered by a build script.
  • On bbmmtech.com three dynamic routes export generateStaticParams and six file routes are marked force-static.
  • Static export has no redirects or headers of its own, so bbmmtech.com uses meta refresh pages and a _headers file read by the host.
  • The flowoapp.com build runs six steps and fails if a prerendered page lacks a title, an h1 or a single canonical link.
  • The Flowo home page renders every platform and campaign variant into the HTML, and an inline head script shows one before first paint.
  • On flowoapp.com only /api/* reaches a worker, and every other path is a static file.

Frequently asked questions

What is the difference between SSR, SSG and static export?
Server-side rendering builds a page on each request, and static generation builds it once during the build. Static export is static generation with no application server left, so the site is a folder of files. BBMM Technologies uses static export for bbmmtech.com, flowoapp.com and textpack.app.
Does Next.js static export support dynamic routes?
Yes, when each dynamic route lists its pages with generateStaticParams. bbmmtech.com does this for articles, tag pages and legal documents. Route handlers also work when they are marked force-static, and their output is saved as a file during the build.
How do redirects work on a static site?
A static export cannot answer with a redirect status by itself. bbmmtech.com serves each old address as a small page with a meta refresh and a canonical link to the new address. Some hosts also read a _redirects file, and flowoapp.com uses one for a rewrite.
Can a React single-page app be prerendered without a framework?
Yes. flowoapp.com bundles a server entry with Vite, renders its components to static markup in a Node script and writes the result into a copy of index.html for each route. The browser bundle then renders the same components over that markup.
How can a static page be A/B tested or localised?
flowoapp.com renders every variant into the HTML and uses a short inline script in the head to show one before first paint. Each language has its own prerendered home page. The script sends a visitor who arrives on the root address to the page for their language.

References

About the author

Mykhailo Boichuk

Chief Technology Officer

Mykhailo builds the systems behind the apps. He works on macOS and iOS performance, and on the sync problems that come with software that has to run offline.