With JavaScript off our site still reads and the FAQ still opens, and sign-up is where it stops
Nakodo has 76 public pages, and its marketing route group contains four "use client" files of its own out of 80 in the repository, plus a few shared ones such as the animated illustrations. That ratio made me curious abo
Nakodo has 76 public pages, and its marketing route group
contains four "use client" files of its own out of 80 in the repository, plus
a few shared ones such as the animated illustrations. That ratio made me
curious about what a visitor with JavaScript switched off actually gets, so I
measured it rather than guessing.
Measuring it
I drove a real browser with script execution disabled over CDP:
await page.cdp("Emulation.setScriptExecutionDisabled", { value: true });
await page.goto("https://nakodo.app/guides/how-much-to-pay-youtubers");
The first thing I learned is about the tooling, not the site. Any
locator-based click gives up, because the automation library needs to run
JavaScript in the page to find and click things:
page.click timed out: CDP request timed out: Runtime.callFunctionOn
So the clicks in this audit are raw input events at coordinates read from the
DOM protocol, which is as close to a real mouse as you can get without a hand:
const { nodeIds } = await page.cdp("DOM.querySelectorAll", { nodeId: root, selector: "summary" });
const box = await page.cdp("DOM.getBoxModel", { nodeId: nodeIds[0] });
const q = box.model.content;
const x = (q[0] + q[2]) / 2, y = (q[1] + q[5]) / 2;
for (const type of ["mousePressed", "mouseReleased"])
await page.cdp("Input.dispatchMouseEvent", { type, x, y, button: "left", clickCount: 1 });
What works
Every page is already there. The pages are server-rendered HTML, so the
document that arrives is the document you read. Stripping scripts and styles
out of three of them and counting words:
| Page | HTML | Readable words |
|---|---|---|
| a guide | 503,973 bytes | 1,946 |
| the home page | 596,325 bytes | 1,858 |
| a calculator page | 504,369 bytes | 1,392 |
The FAQ opens. Every content page ends in a stack of <details> elements,
and <details> is a browser feature, not a React one:
before: { details: 5, open: 0 }
after raw click: { open: 1 }
The main call to action is a plain form. The field on the home page where
you type your website is not a controlled input posting JSON anywhere:
<form action="/login">
<input type="hidden" name="mode" value="signup"/>
<input id="hero-site" name="site" inputMode="url" maxLength="253" ... />
<button type="submit">Start free</button>
</form>
A GET to /login?mode=signup&site=yourbrand.com, which answers 200 with no
JavaScript involved anywhere in the trip.
Navigation works, because every link is an anchor with an href, and the
footer carries 36 of them, which turns out to matter in the next section.
Prices are still local. The two pages that show plan prices read the
visitor's country from a request header on the server, so
the pricing page arrives in the right currency
with the HTML. It is the one thing on this list that works because of an
earlier decision taken for a completely different reason.
What quietly does not
The mobile menu. At 390px the nav collapses to one button. Tapping it with
JavaScript off:
{ menus: 0, dialogs: 0, footerLinks: 36 }
Nothing opens, and nothing says so. The footer is the whole no-JS navigation on
a phone, which is accidental rather than designed, and it is only adequate
because the footer links every family of pages.
Four illustrations stop still. The animated line art on the home page is
CSS, and every animation in it is declared paused. What starts them is an
attribute an observer adds when the drawing scrolls into view:
:is(.illo-play[data-play]:not(:hover), .illo-loop) .illo-pencil { animation-play-state: running; }
With scripts off there are 4 elements with .illo-play and 0 with
[data-play], so the pencil never writes, the stream never flows and the bird
never hops. The drawings are still there, just still.
45 leaves nobody sees. The hero's autumn gust renders its leaves as
server-side markup and animates them with requestAnimationFrame:
g[visibility="hidden"]: 45
That block of markup is 16,786 bytes of the home page, and with no JavaScript
it is 16,786 bytes of leaves that stay invisible forever.
The guided tour never starts. The five field notes on the home page plate
are in the document, but whether a note is open is React state
(open ? "opacity-100" : "opacity-0"), not a CSS hover rule, so they stay at
zero opacity. The notes were deliberately put in the page's own flow instead of
a portal, so that scrolling does not drag them a frame behind, which gets them
most of the way to working without scripts. The last step is state.
The calculators explain themselves and compute nothing. The calculator
page above is 1,392 readable words about engagement rate, the formula, what the
numbers mean, and an FAQ. The four inputs accept typing and no answer appears.
Readable, not usable.
A signed-in visitor is told to sign in. Public pages are static, so the
header works out which button to show in the browser, from the readable auth
cookie. With scripts off that check never runs and the header renders its
static default, so somebody with a live session sees "Sign in" and
"Start free". The link still works; the app checks the session for real.
Where it stops
Sign-up. Not because of a framework limitation, because of a bot check. The
served HTML for /login?mode=signup contains exactly this:
<button type="submit" disabled="">Checking you're not a bot</button>
The submit button is disabled in the HTML, and the only thing that enables it
is a token from a script. There is no honest way around that one: a form that
anybody can post without running anything is a form a bot can post without
running anything.
The sign-in form is a different and more interesting story. Its own HTML says:
<form action="javascript:throw new Error('React form unexpectedly submitted.')">
That is React's sentinel for a form whose action is a client-side function
rather than a Server Action reference. Our sign-in form passes a closure,
because it attaches the visitor's anonymous analytics id before calling the
action:
action={(form) => {
if (mode === "signup" && posthog.__loaded) form.set("anon", posthog.get_distinct_id());
action(form);
}}
One wrapper, written for a small analytics nicety, and the form loses the
no-JavaScript submit that Next and React would otherwise have given it for
free. Nothing warns you. The only way I found out was by reading the attribute
in the served HTML.
Try it
Open any guide with
JavaScript disabled in devtools, and the page, its breadcrumbs, its tables and
its FAQ all behave. Open the home page the same way and
the illustrations are frozen, the leaves are gone and the tour is silent, but
the one form that matters still takes you to the next step. Then look at
the calculators, which
are the clearest case of the whole exercise: all of the explanation, none of
the arithmetic.
I am not going to make a React app work without React. What this audit changed
is smaller: the mobile menu needs a <details> fallback rather than nothing,
and the sign-in form should pass its Server Action straight to action so that
the one page where a person is trying to get in does not depend on a script
that exists to tag an analytics event.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.