Making foxl.ai Crawlable with Next.js Static Export
For two months, Google couldn't index our landing page. The fix: swap Vite CSR for Next.js static export. Here's what we learned about Cloudflare Workers constraints, JSON-LD sitelinks, and why "we'll add SSR later" is a trap.

On this page
Foxl migrated its marketing site from a Vite React single-page application to a Next.js App Router static export. The goal was narrow: make every public route return its title, metadata, structured data, and visible copy in the first HTML response. That improves crawlability and makes the deployed artifact easier to inspect. It does not, by itself, guarantee indexing, rankings, sitelinks, or traffic.
The problem was an extra rendering dependency
The Vite build served one document shell for every application route. Before JavaScript downloaded and React mounted, the meaningful part of the response was effectively:
<body>
<div id="root"></div>
<script type="module" src="/assets/index.js"></script>
</body>Client-side rendering is not categorically invisible to search engines. Google, for example, documents a separate rendering stage for JavaScript pages. The operational problem was that page content depended on that second stage, while link unfurlers, simple HTTP clients, and crawlers without JavaScript received no route body. That made the first response an incomplete representation of the page.
A Cloudflare Worker compensated for part of that gap. For each known route it fetched the root index.html, used HTMLRewriter to replace title, description, canonical, and social metadata, and appended route-specific JSON-LD. The Worker could change the document head, but it did not render the React body. It also duplicated the route catalog in deployment code, so adding a page required keeping the SPA router, Worker metadata table, sitemap, and content registry aligned.
Architecture after the migration
Next.js renders the route tree during the build and writes static files to site/landing/out. Cloudflare serves that directory through a Workers Assets binding. The Worker remains in front of the assets, but its request-time responsibilities are now limited to security headers, canonical redirects, a custom robots.txt, and the llms.txt proxy.
The export contract is explicit in next.config.ts:
const nextConfig = {
output: 'export',
images: { unoptimized: true },
trailingSlash: false,
};This split keeps content rendering in the build and edge behavior in the Worker. A request for a normal page does not invoke a Next.js server. Interactive components still hydrate in the browser, but the page is useful before hydration.
Why static export instead of request-time rendering
The marketing site changes when the repository is deployed. Blog posts, pricing copy, and product pages do not require per-request identity or fresh database reads. Static export therefore matches the content lifecycle and preserves the existing Cloudflare asset deployment.
- Predictable output. The build either emits a complete route or fails. The generated HTML can be inspected before deployment.
- No application server on the page path. Static assets can be cached and served without running React for each request.
- One rendering source. App Router metadata and page content are generated from the same route instead of being partially reconstructed by a Worker route table.
The tradeoff is equally important. With output: 'export', request-dependent server rendering, middleware, and incremental regeneration are not available at runtime. A route handler can be used only when its result is known at build time. Dynamic edge behavior must stay in site/worker.js or move to a separate service.
Implementation outline
- Create App Router pages for each public route and keep interactive sections as client components only where browser state is required.
- Export route metadata for titles, descriptions, canonical URLs, Open Graph fields, and Twitter cards.
- Generate every blog route from
blogPostswithgenerateStaticParams. The build output, not a stale article count, is the authority for how many routes exist. - Emit site-level JSON-LD from the root layout and a
TechArticleobject from each blog page. - Point the Cloudflare Assets binding at
landing/outand delete the Worker's SPA fallback and metadata-rewrite table. - Generate
sitemap.xmlfrom the current blog registry before deployment.
The migration initially lived beside the Vite application so the output could be compared. Repository history shows the active asset directory was switched first. A later cleanup deleted the old Vite tree and moved the Next.js project into site/landing, leaving one production landing implementation.
Structured data and crawler policy
The current root layout publishes an Organization, a WebSite with a documentation search action, a SoftwareApplication, and an item list containing the main navigation links. Blog pages add article-specific publication fields and canonical URLs through the App Router metadata APIs. These objects describe the site to consumers that understand Schema.org. They are not commands to a search engine and do not make a result eligible for every rich presentation.
The sitemap supplies discoverable URLs and modification dates.robots.txt expresses which crawlers may request the site. Neither file can force discovery, indexing, ranking, or inclusion in an AI answer. Those outcomes remain controlled by each external service.
Failure modes to design for
- A dynamic blog slug omitted from
generateStaticParamshas no exported page. Registering metadata without registering its content component can also produce an incomplete article. - A route that reads request headers, cookies, or uncached live data cannot satisfy the static-export contract.
- If
wrangler.tomlpoints at the wrong output directory, the Worker can deploy successfully while serving old or missing assets. - The sitemap generator currently extracts slugs and dates from source text. A formatting change that no longer matches its parser can omit a route, so the generated file must be reviewed.
- Correct metadata with an empty or stale body still fails the core requirement. Validation must inspect content, not only the document head.
Validation
The release gate starts with npm run build in site/landing. A successful build is necessary but not sufficient. Inspect representative files under out and verify that each contains route-specific body text, title, canonical URL, description, and JSON-LD. Confirm that every registered blog slug has an exported HTML file and a sitemap entry.
After deployment, request pages with a plain HTTP client and test the redirect cases handled by the Worker. The acceptance criterion is deterministic: the first response contains the page. Search Console and third-party crawler reports are useful operational signals, but they are not a unit test for the rendering architecture.
Current limitations
The site is deploy-time static, so content remains unchanged until the next build and deploy. Interactive sections still require JavaScript after the initial render. The repository has a build gate but no focused automated test suite for exported HTML contracts, leaving artifact inspection as an important check. Most importantly, static HTML removes a technical obstacle to reading the site; it does not establish why a page was or was not indexed, and it cannot promise a search outcome.
References and further reading
- Next.js static export documentationReference
- Google article structured dataReference