Skip to content

Progressive Enhancement

WebJs is HTML-first by design. Every page is server-rendered to real HTML. Every web component runs its render() on the server, so the component's initial markup is in the response before any script loads. With JavaScript disabled (slow networks, strict CSP, hostile proxies, user preference, ad-blockers, hydration races) the page still paints, content reads, links navigate, and forms submit.

JavaScript is opt-in per interactive behavior, not per component. A counter custom element renders as "0" on the server, and only the +/- click handling needs JS. A dropdown renders its trigger and closed state on the server, and only the open/close toggle needs JS. The HTML is the floor, and @click, signal mutations, and the client router are layered on top.

What works without JavaScript

  • Page rendering. Every page.ts runs on the server and emits HTML. Layouts, metadata, OG tags, the page body all arrive in the first response.
  • Custom elements' initial markup. Every web component's render() runs server-side. Light-DOM components serialize as direct children, and shadow-DOM components emit Declarative Shadow DOM so scoped styles paint before JS loads.
  • Navigation. <a href="..."> is a real link. The client router enhances it into a partial-swap when JS is active. With JS off, the browser performs a standard navigation.
  • Form submissions (write-paths). Server actions are reachable as plain HTML form POSTs:
    <form action="/actions/createPost" method="post">
      <input name="title">
      <textarea name="body"></textarea>
      <button type="submit">Save</button>
    </form>
    Submitting this form works whether or not JavaScript is enabled. The client router upgrades it to a partial-swap submission when active.
  • CSS-driven interactivity. Hover, focus, :checked, :target, <details>/<summary>, native dropdowns (<select>) all work without JS by construction.
  • Suspense fallbacks. The fallback HTML is in the first chunk of the response. Without JS, the user sees the fallback content and won't see streamed-in updates, but the page is never blank.
  • Streaming-injected modulepreload hints. The server emits <link rel="modulepreload"> for every module the page needs. With JS off, those preloads are simply ignored.

What needs JavaScript

Only behaviors that respond to user input or update state in place:

  • @click / @input / @change handlers on custom elements. The button is in the HTML, the handler isn't.
  • Signal updates and reactive re-renders. A counter starts at its server-rendered value; counting up calls signal.set(), which the component's built-in SignalWatcher picks up and re-renders.
  • Client-router partial-swap navigation. With JS off, links still navigate, they just trigger a full-page load instead of a swap. UX degrades to the standard browser experience.
  • Suspense streaming. The fallback paints without JS, but streamed-in updates need scripts to be applied.
  • WebSockets. No fallback; if you need realtime, you need JS.

Notice the asymmetry: showing things works without JS, while reacting to user input or external events requires it. This is the right asymmetry for the web platform.

Display-only components ship zero JavaScript

WebJs takes the asymmetry one step further. A component whose render() is a pure function of its inputs, with no @event handler, no non-state reactive property, no overridden lifecycle hook, no signal or Task, no <slot>, produces identical HTML whether or not its module ever reaches the browser. So the framework detects these statically and strips their import from the served page. The module is never downloaded, and any npm package imported only by display-only components (an icon set, a date formatter) drops out of the importmap.

This is automatic. There is no opt-in keyword and no server-versus-client split to reason about: the same component file is isomorphic, the framework just notices that its browser half would be dead weight. The analysis is deliberately conservative, so anything it cannot prove inert keeps shipping normally. A false "ship" only costs a few bytes; it never breaks behavior.

One boundary to know. Eliding a module means its customElements.define never runs in the browser, so the tag stays an un-upgraded element. That is invisible for a tag that exists only as server-rendered markup, but it would matter if shipping client code observes the registration. The framework detects the statically visible forms of that observation, a literal customElements.whenDefined('the-tag'), a CSS the-tag:defined rule, or an instanceof TheClass check anywhere in your code, and automatically ships the observed component instead of eliding it. You only need to act in the cases static analysis cannot see: a tag name built from a dynamic / interpolated string, or a :defined rule in an external stylesheet outside the module graph. There, give the component an interactivity signal (an @event, a non-state reactive property, or a lifecycle hook) so it ships. This is rare in idiomatic webjs, where display-only elements are read as plain server-rendered markup.

The same applies to whole routes. A page or layout that does no client work, even transitively (no event, signal, client router, npm import, client global, or interactive component anywhere in its subtree), is dropped from the boot script entirely, so a fully-static route ships zero application JavaScript and is pure server-rendered HTML. It still navigates and submits forms via native browser behavior, which is exactly the progressive-enhancement baseline. A layout that only carries interactive components is import-only, so it drops too and the boot emits those components directly; the client router rides along automatically when any of them loads @webjsdev/core.

To keep this working, treat a page / layout as a pure carrier: its only browser-relevant job should be registering the components it imports. It starts shipping its own module the moment its closure does some OTHER client work, which is invisible in tests because it is an elision verdict, not a behavior change. So avoid module-scope client work in a page/layout (a top-level call, a window / document access, a @webjsdev/core/client-router import; routing is automatic) and avoid importing a non-component utility that touches a client global. Put client behavior in a component and server-only code in a .server.{js,ts} file. The verdict is path-aware: client work reached only through a component the page imports (say a shared module-scope signal the component uses) does not pin the page, because the emitted component carries it; only a component-free path from the page to client work ships the page whole. The quick check: page.ts / layout.ts should not appear in the browser's network tab.

It is the no-build framework's answer to dead-JavaScript-on-the-wire elimination, the one benefit React Server Components offer that a progressive-enhancement framework would otherwise lack, achieved here without a bundler, a Flight protocol, or a new mental model. (And it really is just an optimization on isomorphic modules, not an RSC-style server/client split, see Architecture.)

// Elided: pure render, no interactivity. SSR'd HTML is the whole story,
// the browser never downloads this module.
class Badge extends WebComponent {
  render() { return html`<span class="badge">verified</span>`; }
}
Badge.register('status-badge');

// Shipped: a single @click makes it interactive, so its JS is fetched.
class Counter extends WebComponent {
  render() { return html`<button @click=${() => this.inc()}>+</button>`; }
}
Counter.register('my-counter');

Where your npm packages run

An npm package reaches the browser when a module that loads on the client imports it (the boot script loads page/layout modules and the components they register; loading a module runs its import statements). So:

  • Server-only dependency (a date library you only use during SSR): put it behind .server.{js,ts} for a guaranteed-off-the-client result. It also drops automatically if it's used only inside a fully static page/component that gets elided, but the .server boundary is the explicit, always-correct choice.
  • Client-only package (analytics, a polyfill) on a page with no other interactivity: just import it. A side-effect import (import 'analytics') or a guarded window init counts as client work, so the page keeps shipping and the package loads. You do not need a 'use client'-style annotation, the import itself is the signal.

The distinction the framework draws is "does this module do top-level client work?", not "does it import an npm package." A package used only as a value inside a page's body or a display-only component's render never executes on the client, so it rides away when that inert module is elided.

The design rules

Build features so the first paint is the right content, and JavaScript only adds reactivity on top.

1. Use real <a> for navigation

Don't write JS-only click handlers for routing.

// ✅ works without JS, and the router enhances it
<a href="/posts/${post.id}">${post.title}</a>

// ❌ requires JS to navigate
<button @click=${() => router.push(`/posts/${post.id}`)}>
  ${post.title}
</button>

2. Use <form> + a page action for writes

A page.ts may export an action alongside its default render function. A non-GET <form> submission to the page's own URL runs the action, which returns an ActionResult. It works as a plain HTML POST when JS is off, and as a partial-swap submission when JS is on. One piece of code covers both ends of the spectrum, and no form library is involved.

The action validates on the server, then returns one of two outcomes. A success result is a 303 See Other to result.redirect (Post/Redirect/Get). A failure result re-SSRs the same page at 422 with the result on ctx.actionData, so the page repopulates the fields from actionData.values and shows the messages from actionData.fieldErrors.

// app/posts/page.ts
import { html } from '@webjsdev/core';
import { createPost } from '#modules/posts/actions/create-post.server.ts';

// runs only on the server, receives the already-parsed formData
export async function action({ formData }: { formData: FormData }) {
  const title = String(formData.get('title') || '').trim();
  const body = String(formData.get('body') || '').trim();
  const values = { title, body };
  if (!title) {
    return { success: false, fieldErrors: { title: 'Title is required' }, values, status: 422 };
  }
  const post = await createPost({ title, body });
  return { success: true, redirect: `/posts/${post.id}` };
}

export default function NewPost({ actionData }: {
  actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> };
}) {
  const errors = actionData?.fieldErrors || {};
  const values = actionData?.values || {};
  return html`
    <form method="POST">
      <input name="title" value=${values.title || ''} required>
      ${errors.title ? html`<p class="error">${errors.title}</p>` : ''}
      <textarea name="body" required>${values.body || ''}</textarea>
      <button type="submit">Publish</button>
    </form>
  `;
}

With JS off the browser submits, follows the 303, or renders the 422. With JS on the client router applies the 422 in place (no reload, typed input preserved) and follows the 303 via fetch. Avoid the pattern of fetch('/api/...') + a click handler for write-paths. That's JS-required by construction.

3. Make components render correctly on the server

The component's render() output must be the right HTML, not a placeholder waiting for hydration. Bad:

// ❌ first paint is empty, relies on hydration
class PostList extends WebComponent {
  posts = [];
  render() { return html`<ul>${this.posts.map(...)}</ul>`; }
  async firstUpdated() {
    this.posts = await fetchPosts();   // ← only runs in browser
    this.requestUpdate();
  }
}

Good. Fetch on the server, render with real data, no client roundtrip:

// ✅ first paint has the data
// app/posts/page.ts
import { listPosts } from '#modules/posts/queries/list-posts.server.ts';
export default async function Posts() {
  const posts = await listPosts();
  return html`<post-list .posts=${posts}></post-list>`;
}

4. Don't gate read-paths on hydration

Static content (text, images, links, lists, marketing sections, layouts) should be plain HTML or a function returning an html`` template, not a custom element. Custom elements are for when you need state or lifecycle. If your component has no @click, no signal mutation, and no firstUpdated() doing anything, it should probably be a plain function.

5. Use <form>'s built-in validation before reaching for JS

required, pattern, min/max, type="email", type="url" are all enforced by the browser without JS. Add custom JS validation only when these aren't enough.

6. Set SSR-meaningful defaults in the constructor, not connectedCallback

The SSR pipeline constructs each web component (new Cls()), applies its attributes, runs willUpdate and controllers' hostUpdate, reflects reflect: true properties, and calls render(). It does not call connectedCallback, firstUpdated, updated, or any browser-only hook. Those run only in the browser, after the script loads. Whatever state your component should display on first paint must be set in the constructor, derived in willUpdate, or be derivable from the factory's reactive properties on the element's attributes.

// ❌ first paint is empty, initial state set in browser-only hook
class Cart extends WebComponent({ items: prop<Item[]>(Array) }) {
  connectedCallback() {                       // ← server never runs this
    super.connectedCallback();
    this.items = readFromLocalStorage();
    this.requestUpdate();
  }

  render() { return html`<ul>${this.items.map(...)}</ul>`; }
}
// ✅ SSR-safe: sensible default in the constructor, browser hook
//    refines it after hydration
class Cart extends WebComponent {
  items = signal<Item[]>([]);                  // ← SSR uses this

  connectedCallback() {
    super.connectedCallback();
    const stored = readFromLocalStorage();
    if (stored) this.items.set(stored);         // browser-only refinement
  }

  render() { return html`<ul>${this.items.get().map(...)}</ul>`; }
}

For data that genuinely can't be known on the server (a user's localStorage, viewport size, online status, time zone, theme preference), one of three patterns works:

  • Sensible default + browser refinement. The instance signal's initial value ([], 'system', empty) is what SSR renders. connectedCallback reads the browser-only source and writes the signal. Accept that the first paint may flash from the default to the real value once JS arrives. Often fine.
  • Synchronous bootstrap script. If the flash is unacceptable (theme color, RTL direction), emit a tiny inline <script> in the layout's <head> that reads localStorage and writes the value to document.documentElement (e.g. data-theme attribute). CSS reads from that attribute, so the page renders with the correct value before the component upgrades. This is how <theme-toggle> works in this docs site.
  • Send it from the server. If the data is available via a cookie or the request (session, accept-language), read it in the page function and pass it as an attribute or property on the component. The SSR pipeline applies attributes before render(), so the first paint has the right value.

Testing: run with JavaScript disabled

Before marking a feature done, exercise the user's read + write paths with JS turned off. In Chrome DevTools: Settings → Debugger → "Disable JavaScript". Then:

  • Reload the page. Content should paint. Look for an empty container, a stuck loading skeleton, or a JS-rendered widget showing nothing. Those indicate a hydration-dependent first paint.
  • Navigate via the page's links. Each should produce a full page load that lands on the right URL.
  • Submit each form. Each should POST to its action URL and render the resulting page.
  • Interactive widgets (counters, dropdowns) won't react. That's expected and fine. The widget's initial state should still be visible.

A green run here means your feature degrades correctly. Your users on flaky networks, strict CSP, and Chrome's "data saver" mode will thank you.

Why this matters

  • Resilience. Scripts can fail to load, be blocked, or hang. Networks drop. Edges return JS-stripped HTML through aggressive CDN transforms. With HTML-first, your page survives all of it.
  • Performance. Time-to-first-content is determined by HTML, not by waiting for a JS bundle to parse and execute. Even with JS available, the user reads earlier.
  • SEO and link previews. Crawlers and link-preview generators see real content, not a JS shell. Open Graph, Twitter cards, Discord embeds all just work.
  • Accessibility. Real <a> and real <form> elements come with keyboard, screen-reader, and assistive-tech support out of the box. Reinvented JS-only equivalents almost never do.
  • Composability. Server actions can be called via RPC and via plain HTML forms with the same code. Routes work via the client router and via plain browser navigation with the same URLs. One implementation, two ends of the progressive-enhancement spectrum.

Progressive enhancement isn't a feature you enable in WebJs. It's the architecture. Architecture walks through how the request lifecycle produces HTML-first responses. Server Actions shows the form-first pattern. Client Router shows how the SPA-style transitions enhance plain links and forms.