TutorialPart 418 min read

A website on Cloudflare Workers: from local preview to your own domain

Give a Hono API a frontend, understand static assets and server rendering, and take a website through local testing, Git deployments, previews, and a custom domain.

View as Markdown

The reading-time API from part three works from a terminal. Let’s give it a page where someone can paste a paragraph, choose a language, and get an estimate.

That page can live alongside the API on Cloudflare Workers. The browser downloads HTML, CSS, and JavaScript as files. When the reader submits the form, our Hono handler does the calculation. Both use the same hostname.

We’ll take it through local testing, a branch preview, and a release on your own domain. The useful part is learning to explain each response: which file or handler produced it, which settings it used, and whether the browser is showing the latest version.

Start with the reading-api project from part three. You should already have src/index.ts, src/analyze.ts, Hono, and Wrangler. We’ll keep the analysis and validation code, then add a small frontend using plain HTML and JavaScript. Using plain files makes it easier to follow each request before introducing a framework.

A reading-time calculator could run entirely in the browser, keeping the text on the reader’s device. Here we are deliberately reusing the API to learn how a frontend and a Worker fit together. That setup becomes more useful when we add features such as saving drafts.

A website is more than one request

Open the finished tool and the browser asks for the page. The HTML points to a stylesheet and a JavaScript file, so the browser requests those too. Submitting the form creates another request, this time to /api/reading-time.

The first three responses can be files prepared before the visit. The last depends on the paragraph the reader submitted.

Workers Static Assets lets us deploy those files with the Worker. We don’t need a second hosting service or a separate server that reads them from disk. The asset directory contains the public files; the Worker entry point contains the server code.

This is also why “static” doesn’t mean “nothing happens when you click.” A static HTML page can load JavaScript, call an API, and update its contents. The word describes how its initial response is produced.

WHEN DOES THE HTML EXIST?

Three ways to put a page on the screen

A single website can combine these approaches.

  1. Before the visitPrerendered HTML

    A blog post is built into an HTML file, ready to serve when someone requests it.

  2. During the requestServer rendering

    The Worker generates HTML for this request, perhaps using the visitor's session or current data.

  3. After the page loadsBrowser interaction

    JavaScript updates the page. Our reading tool calls an API and displays its response.

Browser interaction works with either prerendered or server-rendered HTML.

Choose when to generate a response based on what it needs to know. Interactivity alone does not require server-rendered HTML.

For our tool, the form is identical for every visitor. Generating that HTML on each request would add work without changing the answer. We’ll serve it as a file and reserve the handler for the calculation.

Where does Pages fit?

Cloudflare now recommends Workers for new projects. Workers supports static files, server code, Git builds, and branch previews. Pages still has its own deployment workflow and Pages Functions, so you will encounter both in existing projects and older tutorials.

For an existing Pages site, the decision deserves more context. Check the framework adapter, Functions routes, redirects, and preview workflow before migrating. Cloudflare’s Pages-to-Workers guide covers those differences.

Give the API a front door

Already have a frontend? Use its files in place of the form below, then follow the routing and deployment steps. If your framework generates a Worker bundle, use its adapter setup as described in the Astro section.

Inside reading-api, create a public directory. The project will look like this:

reading-api/
├── public/
│   ├── index.html
│   ├── styles.css
│   ├── app.js
│   ├── 404.html
│   └── _headers
├── src/
│   ├── analyze.ts
│   └── index.ts
├── package.json
├── package-lock.json
└── wrangler.jsonc

Everything in public should be safe to download. Keep .dev.vars, configuration credentials, and server source outside it. Set the asset directory to the folder you intend to publish, never the repository root.

Tell Wrangler which files to serve

Add this top-level property to the existing wrangler.jsonc. Keep its main, vars, and other settings from part three:

{
  "assets": {
    "directory": "./public",
    "binding": "ASSETS",
    "not_found_handling": "404-page",
    "run_worker_first": ["/api", "/api/*", "/health"]
  }
}

directory selects the files to upload. ASSETS gives our handler a way to request an asset response. 404-page selects an HTML error page for missing assets. The last line makes the API and health paths run the Worker first.

Keep the existing compatibility date while making this change; updating runtime behavior is a separate change to test. Regenerate the types so CloudflareBindings includes ASSETS:

npx wrangler types --env-interface CloudflareBindings

By default, a matching asset can answer without running our handler. The explicit Worker-first paths reserve the API namespace for Hono, even if a file happens to match it.

FOLLOW A REQUEST

One hostname, two ways to answer

Explore the routing rules used in this article. These are illustrated requests; nothing is sent to an API.

  1. Open the homepageGET /
    BrowserStatic assets200 HTML

    The asset service returns public/index.html. The Hono handler does not run.

  2. Load the JavaScriptGET /app.js
    BrowserStatic assets200 JavaScript

    The asset service returns public/app.js. The browser then runs the file.

  3. Analyze a paragraphPOST /api/reading-time
    BrowserWorker · Hono200 JSON

    Hono validates the input and calls analyzeReading(). For valid input, the page receives the estimate and displays it.

  4. Open the API as a pageGET /api/reading-time
    BrowserWorker · Hono405 JSON

    The response includes Allow: POST. Our /api/* rule keeps this request out of the HTML fallback.

  5. Follow a broken linkGET /missing
    Browser navigationStatic assets404 HTML

    The asset service returns public/404.html with status 404. The missing address stays in the URL bar.

API and health paths always run the Worker first. A non-navigation request for a missing file can also reach Hono, which forwards it to ASSETS.

Loading the page and submitting the form reach the same hostname, but only the API request needs the calculation code.

Add the HTML

The page needs a labelled text area, a language selector, a reading-speed input, and somewhere to announce the result. In this version, the page collects input and displays the API’s answer.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Reading time</title>
    <meta
      name="description"
      content="Estimate reading time for English and Arabic text."
    />
    <link rel="stylesheet" href="/styles.css" />
    <script type="module" src="/app.js"></script>
  </head>
  <body>
    <main>
      <h1>How long will this take to read?</h1>
      <p>Paste a paragraph and choose a pace to estimate its reading time.</p>
      <form id="reading-form">
        <label for="text">Text to read</label>
        <textarea
          id="text" name="text" rows="8" dir="auto"
          maxlength="10000" required
        ></textarea>

        <label for="language">Text language</label>
        <select id="language" name="language">
          <option value="en">English</option>
          <option value="ar" lang="ar">العربية</option>
        </select>

        <label for="pace">Reading speed, in words per minute</label>
        <input
          id="pace" name="wordsPerMinute" type="number"
          min="80" max="600" step="1" value="200" required
        />
        <p class="hint">200 is a starting assumption. Adjust it to suit the text.</p>

        <button type="submit" disabled>Estimate reading time</button>
        <p class="hint">Submitting sends your text to this site's API.</p>
      </form>
      <p id="result" role="status" aria-atomic="true"></p>
      <noscript>This tool needs JavaScript to submit text and show a result.</noscript>
    </main>
  </body>
</html>

dir="auto" lets the paragraph determine its text direction. It doesn’t translate the interface: this is an English interface that accepts either language. The button starts disabled and becomes available when the script is ready, so a failed script won’t accidentally submit the text through a default form navigation.

Make it comfortable to use

Use readable type, visible focus, and controls that fit a narrow screen. No layout library is needed for this form.

:root {
  font-family: system-ui, sans-serif;
  line-height: 1.6;
  color: #24211d;
  background: #f8f6f1;
}

* { box-sizing: border-box; }
body { margin: 0; }
main { max-width: 42rem; margin: auto; padding: 2rem 1.25rem; }
h1 { line-height: 1.2; text-wrap: balance; }
label { display: block; margin-block: 1rem 0.35rem; font-weight: 600; }

textarea, select, input, button {
  width: 100%;
  font: inherit;
  padding: 0.75rem;
  border: 1px solid #81786d;
  border-radius: 0.3rem;
}

textarea, select, input { color: inherit; background: #fff; }
textarea { resize: vertical; }
button { margin-top: 1.25rem; color: #fff; background: #29499c; }
button:disabled { opacity: 0.65; cursor: wait; }
:focus-visible { outline: 3px solid #29499c; outline-offset: 3px; }
.hint { font-size: 0.9rem; color: #5e564b; }
#result { min-height: 3rem; font-weight: 600; }

Call the API from the browser

Create public/app.js. The relative URL is the important detail: /api/reading-time uses the origin of the page, whether that page is on localhost, a preview hostname, or your own domain.

const form = document.querySelector("#reading-form");
const button = form.querySelector('button[type="submit"]');
const result = document.querySelector("#result");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (button.disabled) return;

  const fields = new FormData(form);
  button.disabled = true;
  result.textContent = "Estimating…";

  try {
    const response = await fetch("/api/reading-time", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text: fields.get("text"),
        language: fields.get("language"),
        wordsPerMinute: Number(fields.get("wordsPerMinute")),
      }),
      signal: AbortSignal.timeout(10_000),
    });

    const contentType = response.headers.get("content-type") ?? "";
    if (!contentType.includes("application/json")) {
      throw new Error("The server returned an unexpected response. Try again.");
    }

    const data = await response.json();
    if (!response.ok) {
      throw new Error(data.error ?? "The estimate could not be calculated.");
    }

    const words = data.words === 1 ? "word" : "words";
    const seconds = data.seconds === 1 ? "second" : "seconds";
    result.textContent =
      `${data.words} ${words} · about ${data.seconds} ${seconds}` +
      ` at ${data.wordsPerMinute} words per minute.`;
  } catch (error) {
    if (error instanceof DOMException && error.name === "TimeoutError") {
      result.textContent = "The request took too long. Please try again.";
    } else if (error instanceof TypeError) {
      result.textContent = "Couldn't reach the server. Check your connection.";
    } else {
      result.textContent = error instanceof Error
        ? error.message
        : "Something went wrong. Please try again.";
    }
  } finally {
    button.disabled = false;
  }
});

button.disabled = false;

The browser checks the form’s basic constraints before submitting, but the server must still validate every field. A caller can bypass the page entirely. The handler from part three also limits the request body and returns structured errors.

Notice that fetch() doesn’t throw merely because the server returns 400 or 500; we inspect response.ok. We also check the content type before reading JSON. If a routing mistake sends an HTML page, the reader gets an understandable error instead of a JSON parser complaint.

Using textContent displays the result without interpreting it as HTML.

The page and API share an origin, so this interaction needs no cross-origin CORS setup. The endpoint remains public; CORS does not authenticate callers.

Set headers for the public files

For static response headers, create a _headers file in the asset directory:

/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin

These are asset-serving rules. They do not automatically add headers to JSON generated by Hono; configure those in the handler when needed. Likewise, a Hono middleware won’t inspect a homepage request that the asset layer answers directly. If the files must be private, route their requests through authentication before returning any assets.

Make missing pages useful

An unknown webpage should help a human find their way back. An unknown API endpoint should return an API error. Keep that distinction in the handler.

In src/index.ts, replace the existing app.notFound(...) block with:

app.notFound((c) => {
  const path = c.req.path;
  const isApi = path === "/api" || path.startsWith("/api/");
  const isPageRequest = c.req.method === "GET" || c.req.method === "HEAD";

  if (isApi || !isPageRequest) {
    return c.json({ error: "Not found." }, 404);
  }

  return c.env.ASSETS.fetch(c.req.raw);
});

Leave the existing routes, middleware, app.onError, and export default app in place. ASSETS.fetch() asks the asset service for a response; it doesn’t make another public request back through Hono.

Now add the error page:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Page not found · Reading time</title>
    <link rel="stylesheet" href="/styles.css" />
  </head>
  <body>
    <main>
      <h1>That page isn't here.</h1>
      <p>The address may be wrong, or the page may have moved.</p>
      <a href="/">Open the reading-time tool</a>
    </main>
  </body>
</html>

With the configured fallback, a missing page returns this HTML with a 404 status, rather than redirecting every broken link to the homepage. Search engines and HTTP clients need the status as well as the message.

There is one routing detail worth testing explicitly. With 404-page and a compatibility date of April 1, 2025 or later (including ours), browser navigation requests go straight to asset handling unless a Worker-first rule overrides it. They carry Sec-Fetch-Mode: navigate; an ordinary curl request does not. The reserved Worker-first paths keep /health and /api/* in Hono in both cases. Other missing GET requests can reach our fallback and then ask ASSETS for the 404 page. Cloudflare documents this in its static-site routing guide.

Don’t enable single-page-application just because the tool has one page. That option serves index.html as a successful fallback for a client-side router. Our application has no such router, so an invented address should remain a missing page.

Run it locally and follow the requests

From the project directory:

npx tsc --noEmit
npx wrangler dev --local

If Wrangler reports that the compatibility date is newer than its local runtime, update the project dependency with npm install --save-dev wrangler@latest, regenerate types, and rerun the checks. Keep the resulting lockfile change.

Open the URL Wrangler prints, normally http://localhost:8787. Paste a short English paragraph, submit it, then repeat with Arabic and change the reading speed. The text goes to your local Worker in this setup.

Open the browser’s Network panel before reloading. You should see the document, stylesheet, and script. Submit the form and look for the POST request. Its response should be JSON with the word count and estimate; the document itself doesn’t need to reload.

These checks catch different mistakes:

Request or actionExpected result
Open /The form, with its styles and working button
Submit valid textPOST /api/reading-time returns 200 JSON
Send empty text directly to the API400 JSON from the server
Open /api/reading-time in the address bar405 JSON and Allow: POST
Open /api/missing404 JSON
Open /missingYour error page with status 404
Open /health200 JSON, even as a browser navigation
Go offline and submitA connection message; the button becomes usable again

You can reproduce the navigation distinction from the terminal:

curl -i http://localhost:8787/health \
  -H 'Sec-Fetch-Mode: navigate'

curl -i http://localhost:8787/missing \
  -H 'Sec-Fetch-Mode: navigate'

curl -i http://localhost:8787/api/reading-time \
  -H 'Content-Type: application/json' \
  --data '{"text":"Small tools deserve good interfaces.","language":"en"}'

The last request should count five words. At the default 200 words per minute, the API rounds the estimate up to two seconds. This is an adjustable reading-speed assumption, not a measurement of a particular reader.

Deploy the site, then automate releases

For a first manual release, check the bundle before publishing:

npx wrangler deploy --dry-run

This prepares the deployment without making it live. When you want to publish the example, sign in and deploy:

npx wrangler login
npx wrangler deploy

Open the printed URL and submit the form there. A successful local request cannot check the deployed hostname or configuration.

Once the project is in a GitHub or GitLab repository, connect that repository under the Worker’s Settings → Builds. Review these settings before relying on automatic deployment:

SettingThis plain HTML project
Production branchThe branch you release from, for example main
Root directoryThe directory containing this project’s package.json
Build commandnpx wrangler types --env-interface CloudflareBindings && npx tsc --noEmit
Deploy commandnpx wrangler deploy
Worker nameMust match name in wrangler.jsonc

The build command regenerates the bindings interface before checking TypeScript, so it also works in a fresh checkout without generated types. Wrangler bundles the Worker during deployment; our plain HTML needs no compilation. Commit the package lockfile so dependency installation uses the recorded versions. If you use Astro instead, its build command needs to generate the site, typically with npm run build, before deployment. Workers Builds configuration separates that build step from the deploy command.

FROM LOCAL TESTING TO PRODUCTION

Check the application at each stage

Each environment catches problems the previous one cannot.

  1. 01 · LocalPage and API

    Run Wrangler. Test valid and invalid inputs, missing URLs, and the browser's error messages.

  2. 02 · PreviewDeployed configuration

    Deploy the branch. Check its variables and bindings, then share the URL for review.

  3. 03 · ProductionThe reader's experience

    Merge into the production branch. Open the real domain and submit the form after deployment.

Test the complete interaction at the deployed URL, including its error cases.

Review a branch before merging, then check the real domain after deployment. Each stage catches a different class of mistake.

A preview is a deployed environment

A branch preview lets someone review a change at a deployed URL before it reaches the production branch. It has its own configuration; the local tests alone cannot check that configuration.

As of November 6, 2026, Worker Previews uses npx wrangler preview and requires Wrangler 4.135.0 or later. Update the project’s Wrangler dependency if you need that workflow; updating a global installation won’t change the version the project runs.

New Workers connected to Builds use this preview model by default. Older projects can still have the previous model until explicitly switched. In branch controls, enable preview builds and check the preview command. An older Worker may still use the previous command.

For the new preview model, add a top-level previews block to wrangler.jsonc:

{
  "previews": {
    "vars": {
      "SERVICE_NAME": "reading-api-preview",
      "DEFAULT_WPM": 200
    }
  }
}

Keep assets at the top level: Wrangler uploads the branch’s files when you run npx wrangler preview. The preview configuration guide explains which settings belong in each block. Top-level variables are not automatically the preview’s variables.

After deploying the preview, open /health and confirm it reports reading-api-preview, then submit the form. Once we add storage, we must also choose the preview’s database explicitly; a separate URL does not imply separate data.

Protect the preview with access controls if its content is private. Anyone who can reach an unprotected URL can use it.

Put it on your own domain

The workers.dev URL is enough to use the application. A domain gives it a name you control.

For an active domain on Cloudflare, add a Custom Domain to the existing configuration, using an unused hostname you own:

{
  "routes": [
    { "pattern": "reading.example.com", "custom_domain": true }
  ]
}

Deploy again. Cloudflare manages the DNS record and certificate for the Custom Domain. An existing conflicting record can prevent setup; the DNS article explains how to inspect the zone.

The frontend doesn’t need a new API URL. Its relative fetch still goes to /api/reading-time, now on reading.example.com. Repeat the same check on a branch preview and it calls that preview’s hostname instead.

Choose one canonical hostname for a public site. If you serve both www and the apex, redirect the secondary hostname to the preferred one, preserving paths. Give each content page a useful title and description, keep canonical URLs on the public domain, and include published content in a sitemap. Test redirects on a nested URL as well as the homepage.

Cache the files with their next update in mind

Cloudflare’s asset service manages delivery of the uploaded files. For ordinary asset responses, its documented browser-cache default is Cache-Control: public, max-age=0, must-revalidate, with an ETag for checking whether the file changed. A browser can retain a copy, then revalidate it before reuse. See the asset headers reference.

That default suits our fixed filenames. If we edit app.js, its address stays /app.js. Telling the browser to reuse it for a year would make the next fix harder to deliver.

Later, a bundler might generate app.a1b2c3.js. If every content change produces a different filename, long-lived immutable caching becomes useful: the new HTML references the new file. Apply that policy to the fingerprinted assets, not indiscriminately to HTML or everything under /.

AFTER YOU DEPLOY A FIX

Will the browser get the new JavaScript?

Assume the browser already cached version 1, its cache is enabled, and a later page load requests the script again. Compare three policies.

  1. Fixed filename · revalidate/app.js · max-age=0, must-revalidate
    Cached version 1Check the ETagDownload version 2

    The URL stays the same. Because the deployed file changed, validation returns the new body. If unchanged, a 304 response lets the browser reuse its copy.

  2. Fixed filename · cache for a year/app.js · max-age=31536000, immutable
    Cached version 1Copy is still freshReuse version 1

    The browser can reuse the old file without checking the server. Deploying new bytes at the same URL does not invalidate that fresh browser copy.

  3. Hashed filename · cache for a year/app.b2.js · max-age=31536000, immutable
    Cached app.a1.jsNew HTML requests app.b2.jsDownload version 2

    The new URL is not in this browser's cache. Each changed bundle gets a new name; HTML must still be refreshed so it references that name.

Keep revalidation for this tutorial's fixed filenames. Use long immutable caching only for files whose URLs change with their contents.

Compare what happens on a later visit after you deploy a JavaScript change. This models browser caching, not Cloudflare's internal asset cache.

A deployment cannot clear a reader’s browser cache, and an already-open page keeps running the JavaScript it loaded. Revalidation helps on the next load; hashed filenames help when the browser receives HTML that references the new filename. MDN’s HTTP caching guide explains both mechanisms.

Our API keeps the Cache-Control: no-store header from part three. The calculation depends on submitted text, and this tutorial doesn’t add a response cache for it.

What changes when you use Astro?

A single form needs little templating. A blog benefits from shared layouts, navigation, article metadata, and a content collection; a framework can manage that repetition.

This website uses Astro. Its articles are prerendered into HTML, while routes that need the incoming request, such as the contact form, opt into server rendering. Both are deployed through the Cloudflare adapter. The source files differ from our small project, but the division of work is familiar: prepared pages for reading, server code for actions.

For a framework project, point hosting at the build output, not the folder where you edit components. A .astro or .tsx file is source code, not a finished webpage. Use the framework’s current adapter instructions; an adapter or Cloudflare’s Vite plugin may generate the Worker entry point and asset configuration for you. Don’t paste our hand-written main and assets.directory over that generated setup.

WHAT LEAVES A FRAMEWORK BUILD?

One project, two kinds of output

An Astro project with prerendered pages and on-demand routes produces both. The browser only downloads the public output.

Pages, components, stylesAstro + Cloudflare adapter
  1. Public assetsFiles the browser can request
    HTML · CSS · client JavaScriptStatic asset service

    Prerendered articles and frontend resources become downloadable files. Anything bundled into client JavaScript is visible to visitors.

  2. Server bundleCode the Worker executes
    On-demand routes · server logicWorker runtime

    The Worker handles requests for these routes and returns responses. Its bundle is not served as a downloadable asset; secrets belong in runtime bindings.

Deploy the adapter's output. Pointing the asset directory at the repository would expose source files instead of serving the built site.

A framework build can produce both downloadable files and a Worker bundle. The adapter decides their directory layout; inspect your project's output rather than assuming these are folder names.

Run the build and inspect those two outputs. Check which pages exist as HTML, which routes need the Worker, and which JavaScript files a visitor will download.

When a deployment goes wrong

Start by identifying the stage that failed. If dependency installation failed, the new page hasn’t reached the build step. If the build passed but the page can’t call its API, inspect the actual HTTP response.

SymptomFirst thing to check
Page loads but the button stays disabledDid /app.js load, and does the browser console show an error?
Form gets HTML instead of JSONThe request URL, asset fallback, and Worker-first paths
Browser navigation and curl disagreeThe Sec-Fetch-Mode header and routing rules
New HTML uses old JavaScriptCache headers and whether filenames change with their contents
API works locally but fails in a previewPreview variables, secrets, and resource bindings
Build never reaches the compilerDependency-install logs, package lockfile, and build cache

That last case happened when publishing the previous article on this site. Cloudflare’s build logs showed a corrupted cached esbuild archive, followed by an ETXTBSY error during installation. The failure was in dependency installation, before Astro compiled the article. Logs like those give you a reason to clear the build cache and retry the same commit before changing application code.

If a deployed code change breaks the application, use the rollback procedure from part three, then test the page and API together. A rollback doesn’t remove copies already held by browsers or reverse database writes. That’s another reason to think about asset filenames before choosing long cache lifetimes.

What do the page requests cost?

At the time of writing, Cloudflare lists direct static-asset requests as free and unlimited, with no additional asset-storage charge. Requests that invoke the Worker script follow Workers pricing. The billing reference distinguishes the two.

In our example, loading the HTML, CSS, and JavaScript through asset serving does not require the analysis handler. Submitting a paragraph does. If you configure the Worker to run before every asset request, those invocations count too, even when the eventual response is a file.

Build usage, domain registration, and any services you add have their own costs. Estimate asset requests and Worker invocations separately.

Make one small release

Change the page heading on a branch and push it. Check that the new heading appears on the branch preview while the production page stays unchanged. After reviewing and merging it, open the real domain and submit the form again. That exercises the release workflow you have just configured.

The same structure can support a feedback form or a small dashboard: public files for the interface, Worker handlers for server-side actions, and a preview where you can check both together.

The next useful feature is remembering a draft. That brings us to persistent data: how to save it, query it, and keep preview writes away from production. We’ll build on this application when we get to D1.

If you’re helping someone deploy their first website on Cloudflare, share this guide with them. Which part has caused you the most trouble: routing, previews, or caching? Send me the example, or share what you learned alongside the article on X or LinkedIn.

Topics