There was a time when starting a website felt impossible without spending two evenings comparing frameworks.

One had better routing. Another had faster server rendering. A third promised edge deployment, automatic caching, partial hydration, resumability, streaming, islands, signals, server components, and probably world peace in the next minor release.

The strange part was that most of my projects did not need any of that.

They needed forms that worked. Pages that loaded quickly. Search engines that could read the content. A backend that did not fall apart after six months. And code that another developer could understand without watching a four-hour conference talk first.

Still, every new project began with a fresh dependency graph and a new collection of conventions. The stack looked modern, but the websites were not getting better. They were getting heavier, harder to debug, and more dependent on framework behavior that nobody on the team fully understood.

At some point, the obvious question finally appeared.

What would happen if the framework stopped being the starting point?

The framework had quietly become the architecture

The first warning sign was not performance. It was the amount of code required to display fairly ordinary content.

A product page had a title, description, price, availability status, image gallery, and an Add to cart button. Nothing unusual. Yet the project had client-side routing, global state, query caching, a hydration layer, generated API hooks, schema validation, an animation package, and three different utilities for working with CSS classes.

The initial page load downloaded around 420 KB of compressed JavaScript. After decompression and execution, the browser processed much more. On a modern laptop, everything looked fine. On an older Android phone, the page spent noticeable time parsing and executing code before becoming interactive.

The HTML sent by the server was barely useful without JavaScript.

This was not a failure of one particular framework. The framework was doing exactly what it was designed to do. The failure was architectural: the application had been treated as a client-side program even though most of it was a document.

That distinction changed how later projects were designed.

A website can contain application-like parts without becoming a full client-side application. A search filter may need interactivity. A shopping cart may need local state. An image editor definitely needs JavaScript. But the header, article body, product description, footer, breadcrumbs, and legal pages do not need to be reconstructed by a runtime after the browser receives them.

The browser already knows how to render documents. It has been doing that for decades.

Once this became obvious, the amount of JavaScript started shrinking naturally. Not because JavaScript was bad, but because it was no longer being asked to solve problems that HTML, CSS, HTTP, and the server had already solved.

The first experiment was intentionally boring

The next project was a small internal dashboard used by around 80 people. The previous version had been built as a single-page application. It was not terrible, but simple changes regularly crossed several layers.

Adding one field could require updating the database schema, backend serializer, API type, generated frontend client, validation schema, state definition, form component, and optimistic update logic.

For the rewrite, the main rule was simple: the server would return complete HTML, and JavaScript would be added only where interaction really needed it.

The dashboard used normal links, normal forms, server-side validation, and small browser-side modules. There was no client-side router. Navigation used HTTP. Failed form submissions returned the same page with validation errors. Successful actions used redirects.

It sounded old-fashioned. It also worked surprisingly well.

Here is a simplified version of the pattern used for editing a project.

// Runtime: Node.js with Express

import express, { Request, Response } from "express";
import { z } from "zod";
import { escape } from "html-escaper";

const app = express();

app.use(express.urlencoded({ extended: false }));

type Project = {
  id: string;
  name: string;
  owner: string;
  status: "active" | "paused";
};

const projects = new Map<string, Project>([
  [
    "p-100",
    {
      id: "p-100",
      name: "Billing migration",
      owner: "Nina",
      status: "active",
    },
  ],
]);

const updateProjectSchema = z.object({
  name: z.string().trim().min(3).max(120),
  owner: z.string().trim().min(2).max(80),
  status: z.enum(["active", "paused"]),
});

function layout(title: string, content: string): string {
  return `
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>${escape(title)}</title>
        <link rel="stylesheet" href="/assets/app.css">
        <script type="module" src="/assets/app.js"></script>
      </head>
      <body>
        <header class="site-header">
          <a href="/">Projects</a>
        </header>

        <main class="container">
          ${content}
        </main>
      </body>
    </html>
  `;
}

function projectForm(
  project: Project,
  errors: Record<string, string> = {},
): string {
  return `
    <form method="post" action="/projects/${escape(project.id)}">
      <div class="field">
        <label for="name">Project name</label>
        <input
          id="name"
          name="name"
          value="${escape(project.name)}"
          required
          minlength="3"
          maxlength="120"
        >
        ${
          errors.name
            ? `<p class="error">${escape(errors.name)}</p>`
            : ""
        }
      </div>

      <div class="field">
        <label for="owner">Owner</label>
        <input
          id="owner"
          name="owner"
          value="${escape(project.owner)}"
          required
        >
        ${
          errors.owner
            ? `<p class="error">${escape(errors.owner)}</p>`
            : ""
        }
      </div>

      <div class="field">
        <label for="status">Status</label>
        <select id="status" name="status">
          <option
            value="active"
            ${project.status === "active" ? "selected" : ""}
          >
            Active
          </option>
          <option
            value="paused"
            ${project.status === "paused" ? "selected" : ""}
          >
            Paused
          </option>
        </select>
      </div>

      <button type="submit">Save changes</button>
    </form>
  `;
}

app.get("/projects/:id", (req: Request, res: Response) => {
  const project = projects.get(req.params.id);

  if (!project) {
    res.status(404).send(
      layout(
        "Project not found",
        `
          <h1>Project not found</h1>
          <p>The requested project does not exist.</p>
        `,
      ),
    );
    return;
  }

  res.send(
    layout(
      `Edit ${project.name}`,
      `
        <h1>Edit project</h1>
        ${projectForm(project)}
      `,
    ),
  );
});

app.post("/projects/:id", (req: Request, res: Response) => {
  const project = projects.get(req.params.id);

  if (!project) {
    res.status(404).send(
      layout(
        "Project not found",
        `
          <h1>Project not found</h1>
          <p>The requested project does not exist.</p>
        `,
      ),
    );
    return;
  }

  const result = updateProjectSchema.safeParse(req.body);

  if (!result.success) {
    const errors: Record<string, string> = {};

    for (const issue of result.error.issues) {
      const field = issue.path[0];

      if (typeof field === "string" && !errors[field]) {
        errors[field] = issue.message;
      }
    }

    const submittedProject: Project = {
      id: project.id,
      name: String(req.body.name ?? ""),
      owner: String(req.body.owner ?? ""),
      status:
        req.body.status === "paused"
          ? "paused"
          : "active",
    };

    res.status(422).send(
      layout(
        `Edit ${project.name}`,
        `
          <h1>Edit project</h1>
          ${projectForm(submittedProject, errors)}
        `,
      ),
    );
    return;
  }

  projects.set(project.id, {
    ...project,
    ...result.data,
  });

  res.redirect(303, `/projects/${project.id}`);
});

app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

There is nothing especially clever here.

That became one of its best qualities.

The browser provides keyboard navigation, form submission, history, validation hints, focus behavior, and accessibility semantics. The server remains the source of truth. Refreshing the page does not destroy state because important state is not trapped inside a component tree.

The 303 redirect after a successful form submission also prevents accidental duplicate submissions when the user refreshes the result page. That behavior came from understanding HTTP, not from installing another package.

JavaScript became an enhancement instead of a requirement

Removing the application shell did not mean removing interactivity.

Users still wanted instant filtering, confirmation dialogs, automatic draft saving, expandable sections, and status updates. The difference was that those features were layered on top of a working document.

If JavaScript failed to load, the form still submitted. Links still opened. Content was still visible. The website lost convenience, not its basic ability to function.

That sounds like progressive enhancement, because that is exactly what it is. The name sometimes makes it sound like a historical technique kept alive by people who still miss XHTML. In practice, it is a useful failure strategy.

Client-side code fails more often than developers like to admit.

A cached HTML document may reference an outdated bundle. A browser extension may interfere with page scripts. A content security policy may block an asset. A deployment may briefly serve mismatched versions. A device may run out of memory. A third-party script may throw an exception before the application starts.

The question is not whether JavaScript can fail. It can.

The useful question is how much of the page fails with it.

For the dashboard, small modules were attached through data attributes. They did not own the page. They only improved specific elements.

// Browser module loaded with type="module"

class ProjectFilter {
  constructor(root) {
    this.root = root;
    this.input = root.querySelector("[data-filter-input]");
    this.rows = Array.from(
      root.querySelectorAll("[data-project-row]"),
    );
    this.emptyState = root.querySelector("[data-empty-state]");

    if (!this.input) {
      return;
    }

    this.input.addEventListener(
      "input",
      () => this.update(),
    );

    this.update();
  }

  update() {
    const query = this.input.value
      .trim()
      .toLocaleLowerCase();

    let visibleCount = 0;

    for (const row of this.rows) {
      const searchableText =
        row.dataset.searchText?.toLocaleLowerCase() ??
        row.textContent?.toLocaleLowerCase() ??
        "";

      const visible =
        query.length === 0 ||
        searchableText.includes(query);

      row.hidden = !visible;

      if (visible) {
        visibleCount += 1;
      }
    }

    if (this.emptyState) {
      this.emptyState.hidden = visibleCount !== 0;
    }
  }
}

class ConfirmForm {
  constructor(form) {
    this.form = form;
    this.message =
      form.dataset.confirmMessage ??
      "Are you sure?";

    form.addEventListener("submit", (event) => {
      const submitter = event.submitter;

      if (
        submitter instanceof HTMLElement &&
        submitter.dataset.skipConfirmation === "true"
      ) {
        return;
      }

      if (!window.confirm(this.message)) {
        event.preventDefault();
      }
    });
  }
}

class AutosaveField {
  constructor(input) {
    this.input = input;
    this.storageKey =
      input.dataset.autosaveKey ?? "";

    if (!this.storageKey) {
      return;
    }

    this.restore();

    input.addEventListener(
      "input",
      this.debounce(() => this.save(), 250),
    );

    const form = input.closest("form");

    form?.addEventListener("submit", () => {
      localStorage.removeItem(this.storageKey);
    });
  }

  restore() {
    const savedValue =
      localStorage.getItem(this.storageKey);

    if (
      savedValue !== null &&
      this.input.value.trim() === ""
    ) {
      this.input.value = savedValue;

      this.input.dispatchEvent(
        new Event("input", { bubbles: true }),
      );
    }
  }

  save() {
    localStorage.setItem(
      this.storageKey,
      this.input.value,
    );
  }

  debounce(callback, delay) {
    let timeoutId;

    return (...args) => {
      window.clearTimeout(timeoutId);

      timeoutId = window.setTimeout(
        () => callback(...args),
        delay,
      );
    };
  }
}

function startApplication() {
  document
    .querySelectorAll("[data-project-filter]")
    .forEach((element) => {
      new ProjectFilter(element);
    });

  document
    .querySelectorAll("form[data-confirm]")
    .forEach((form) => {
      new ConfirmForm(form);
    });

  document
    .querySelectorAll("[data-autosave-key]")
    .forEach((input) => {
      new AutosaveField(input);
    });
}

startApplication();

This code is not framework-free as a badge of honor. It is framework-free because the problem is small enough to stay understandable without one.

The filter owns only filtering. The confirmation behavior owns only confirmation. The autosave behavior owns only local draft storage. None of them controls navigation, data fetching, authentication, page rendering, or the lifetime of the entire interface.

Could these modules be written as components in a framework? Of course. But what would improve?

That question became a useful filter when selecting tools.

Performance improved before any serious optimization work

The first performance win did not come from better memoization, route-level code splitting, or replacing one bundler with a faster bundler.

It came from sending less code.

The old dashboard shipped roughly 310 KB of compressed JavaScript on the initial route. The rewritten version shipped about 34 KB, including a small collection of shared modules. Some pages used less.

The difference was more visible on slower hardware than on a fast office laptop.

JavaScript has several costs:

  • it must be downloaded;

  • it must be decompressed;

  • it must be parsed;

  • it must be compiled;

  • it must be executed;

  • it may allocate memory;

  • it may trigger style recalculation and layout;

  • it must often run again after navigation.

A compressed bundle size does not fully describe those costs. Two bundles with the same transfer size can behave very differently depending on what they execute.

HTML is also code sent over the network, but browsers can parse and render it incrementally. The document can become visible while the rest is still arriving. A client-rendered application often delays meaningful content until JavaScript has downloaded and executed.

Server-rendered HTML also made caching easier to reason about.

Public pages used cache-control headers and entity tags. User-specific pages remained private. Static assets were fingerprinted and cached for a year. The HTML pointed to immutable filenames, so there was no need to invalidate assets manually.

This was another recurring lesson: browser performance becomes easier when the architecture follows HTTP instead of hiding it.

Before adding lazy loading, ask why the resource is so large.

Before adding client caching, ask whether the server response can be cached.

Before adding a state management library, ask whether the state belongs in the URL, a form, a cookie, or the database.

Before adding a router, ask whether normal navigation is actually too slow.

Sometimes the answer still supports adding the tool. But now the tool has a job.

URLs turned out to be better state containers than expected

One of the old application’s annoying bugs involved filters.

A user could select a team, status, date range, and sort order. The interface updated correctly, but copying the URL did not preserve the result. Refreshing the page reset everything. Browser navigation behaved inconsistently because some state lived in the router and some lived in a global store.

The rewrite placed filter state in query parameters.

A filtered page could look like this:

/projects?team=platform&status=active&sort=updated

This immediately fixed several problems.

The page became shareable. Refresh worked. Back and forward navigation worked. Server logs showed which filtered views were actually used. Automated tests could request the exact state without reproducing a sequence of clicks.

There was less application state because some of it had become navigation state.

This is a small design decision, but it changes the shape of the code. Instead of synchronizing a URL with a store, the URL becomes the source of truth. The server reads it, generates the correct document, and optional JavaScript improves the interaction.

Not every state belongs in the URL. Unsaved text, open tooltips, drag positions, and temporary UI details usually do not. But filters, search terms, pagination, selected tabs, report ranges, and sort order often do.

Browsers already have a distributed state system. It is called the address bar.

The backend also became easier to change

Frontend complexity often leaks into backend design.

When every interface update happens through a JSON API, the API becomes responsible for supporting all possible client states. A simple page may require several requests, each with its own loading, failure, retry, and synchronization behavior.

Server-rendered pages change that boundary.

The backend can query several data sources, combine the results, apply authorization rules, and return one document. The browser does not need to understand the internal service structure.

This reduced accidental coupling in the dashboard.

The frontend no longer knew that project metadata came from PostgreSQL, user details came from an internal directory service, and status summaries came from Redis. It received HTML representing the current state.

JSON endpoints still existed where they made sense. A live search widget used one. A chart loaded data asynchronously. A background import displayed progress through server-sent events.

The point was not to ban APIs.

The point was to stop turning every page into an API client by default.

There is also a security benefit. When authorization lives close to server-side rendering, fewer sensitive fields are sent to the browser and hidden later. The server decides what the user may see before producing the response.

Client-side access checks can improve the interface, but they should never be the real security boundary. That rule is obvious, yet large client applications make it surprisingly easy to blur.

Framework knowledge is still useful

After all this, frameworks did not disappear from my work.

They became more valuable because they were used more selectively.

A complex scheduling interface with drag-and-drop interactions, optimistic updates, local undo history, and real-time collaboration benefits from a component model and predictable state updates.

An image editor running mostly in the browser is an application, not a document with a few enhanced controls.

A large team may benefit from conventions enforced by a mature framework, even when a smaller solution is technically possible.

The mistake was not using frameworks. The mistake was treating every website as if it had the same needs as the most interactive product on the internet.

The decision now begins with the interface.

How much of it is static content?

How much state must survive navigation?

Does the page need to work before JavaScript starts?

Can the server produce the final HTML?

Is offline support required?

Will users spend hours inside one screen, or will they mostly navigate between pages?

How much client-side state must be coordinated?

How many developers will maintain the project?

These questions lead to a stack. Starting with a stack usually leads to finding excuses for it.

The real cost was not bundle size

Reducing JavaScript improved performance, but the larger improvement was maintenance.

Framework churn creates costs that do not appear in Lighthouse reports.

Dependencies change their configuration formats. Plugins become abandoned. Recommended project structures are replaced. Routing conventions move. Rendering modes multiply. Build tools change. Packages that were essential two years ago become compatibility layers nobody wants to touch.

Meanwhile, the website still needs to display a form.

Stable browser APIs are not exciting, but they age well. A standard form submission written many years ago can still work today. The same is true for links, semantic HTML, cookies, cache headers, and URL parameters.

That stability has practical value.

Code is read more often than it is written. A boring solution that remains obvious after three years can be cheaper than an elegant abstraction that requires historical knowledge of a framework release.

One useful habit is to calculate complexity across time.

The initial implementation may take two days less with a familiar framework. But how many upgrades will happen? How many developers will need onboarding? How many production bugs will involve hydration, stale client state, race conditions, or mismatched caches?

There is no universal answer. Still, asking the question changes decisions.

The fastest way to launch is not always the fastest way to keep launching changes.

What building better websites means now

Better no longer means using fewer tools at all costs.

It means understanding what each layer already gives us before replacing it.

HTML gives the page structure and meaning.

CSS handles more interface behavior than many projects allow it to handle.

HTTP provides caching, redirects, authentication mechanisms, content negotiation, and clear request-response semantics.

URLs provide navigation and shareable state.

The server protects secrets, enforces authorization, combines data, and can produce complete documents.

JavaScript adds interactions that cannot be expressed cleanly through the other layers.

Frameworks organize complex client-side behavior when that complexity is real.

The order matters.

Starting from browser and protocol capabilities creates a smaller base. A framework can still be added when the project reaches the point where it solves more problems than it introduces.

And yes, sometimes the result looks less impressive in a repository.

There may be no giant component directory. No custom hook for reading one query parameter. No state library controlling whether a dialog is open. No loading skeleton shown for content the server already had before rendering the page.

The website simply loads, works, and remains understandable.

That is harder to demonstrate in a technology comparison. It is much easier to appreciate six months later, when a small feature request does not require rebuilding half the application.

Frameworks are useful tools. They are just poor substitutes for architecture.

The biggest improvement in my web development process came from stopping the search for the perfect stack and spending more time understanding the page itself.

That change was not revolutionary.

It was mostly a return to things the browser had been quietly doing well all along.