DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Four calculators are four static pages with one client component each, and the index that would list them does not exist

Nakodo is a web app that finds creators for a brand and emails them. Alongside it there is a small set of free tools that need no account, and they are the only pages on the site where a visitor types something in. There are four of them:

Every other public page on the site is prose from a TypeScript array. These four are the same prose pages with exactly one interactive component dropped into the middle of them. This post is about what that costs and the two places it fails the build rather than the page.

The page is a content entry plus a component

A tool page has a content entry like every guide and glossary page: slug, h1, meta title, meta description, keywords, a summary, sections, FAQs. Nothing in there knows about a calculator. The route is what joins the two:

// The calculator each tool page shows under its summary. A slug in
// src/content/tools without one here fails the build.
const WIDGETS: Record<string, () => React.ReactNode> = {
  "engagement-rate-calculator": EngagementRateCalculator,
  "sponsorship-cost-calculator": SponsorshipCostCalculator,
  "tiktok-engagement-rate-calculator": TiktokEngagementRateCalculator,
  "instagram-engagement-rate-calculator": InstagramEngagementRateCalculator,
};

export const dynamicParams = false;

export const generateStaticParams = () => entriesOf("tool").map((e) => ({ slug: e.page.slug }));

export default async function ToolPage({ params }: PageProps<"/tools/[slug]">) {
  const entry = requireEntry("tool", (await params).slug);
  const Widget = WIDGETS[entry.page.slug];
  if (!Widget) throw new Error(`No calculator for /tools/${entry.page.slug}`);
  return (
    <ContentPage entry={entry} extraLd={[/* WebApplication, free, no sign-in */]}>
      <Widget />
    </ContentPage>
  );
}
Enter fullscreen mode Exit fullscreen mode

Two different mistakes are caught by two different throws.

Adding a calculator component and forgetting the content entry means there is no page at all, which generateStaticParams makes obvious: the route never appears. Adding a content entry and forgetting the component is the dangerous one, because the page would otherwise render perfectly, with a summary that promises a calculator and nothing under it. generateStaticParams runs the component for every slug at build time, so that throw happens during next build and not in front of a reader.

dynamicParams = false is what makes an unknown slug a 404 rather than an attempt to render. You can see both halves of that from a terminal:

$ curl -s -o /dev/null -w '%{http_code}\n' https://nakodo.app/tools/engagement-rate-calculator
200
$ curl -s -o /dev/null -w '%{http_code}\n' https://nakodo.app/tools/does-not-exist
404
Enter fullscreen mode Exit fullscreen mode

The index page does not exist, and that is in a test

There is no /tools page. The directory holds one entry, [slug], and the sitemap has four tool URLs and no hub. Our content registry gives each family a hub, and the tools point at the guides:

tool: { base: "/tools", name: "Free tools", hub: { name: "Guides", path: "/guides" } },
Enter fullscreen mode Exit fullscreen mode

The reason is editorial rather than technical. A page listing four calculators would be four links and no content, which is the kind of page that ends up in search results instead of the calculator somebody wanted. So the tools are linked from the guides index and from the guides that explain the numbers, and /tools itself is in the list of paths a test asserts are not public:

test("the app stays private", () => {
  for (const path of ["/campaigns", "/campaigns/abc", "/settings/plan", "/admin/runs", "/onboarding", "/guidesx", "/for", "/tools"]) {
    assert.ok(!isPublicPath(path), `${path} should need a session`);
  }
});
Enter fullscreen mode Exit fullscreen mode

/tools/ with the slash is a public prefix; /tools on its own is not. That looks like a typo and is the opposite: it means a stray link to the hub that does not exist gets caught as a redirect to the sign-in page rather than quietly 404ing for crawlers.

Nothing typed in reaches us

The whole calculator is a client component with useState and no fetch, no server action, no query string:

// The calculators on /tools pages. Everything runs in the browser; nothing
// typed here is sent anywhere.
Enter fullscreen mode Exit fullscreen mode

That is worth being exact about, because the page is not unobserved. The site runs product analytics with session recording on, so a recording of this page exists. The setting that matters is in code rather than in the project's dashboard:

// Set here so the project's settings can't loosen it: every field is
// recorded as asterisks.
session_recording: { maskAllInputs: true },
Enter fullscreen mode Exit fullscreen mode

So the recording shows somebody filling in three rows, and every number in it is a row of asterisks. No input value is captured as an event either, because the component never sends one.

The cost of all this is that the calculator has no shareable state. There is no ?views=40000 to copy to a colleague. For a tool whose whole job is to be used once in a browser tab, I would rather have that than a URL that carries a creator's numbers into someone's history, link preview and analytics.

Rows are strings, and a half-typed number is a valid string

The engagement calculators take up to ten videos, three rows to start with:

type Row = { views: string; likes: string; comments: string };
const EMPTY: Row = { views: "", likes: "", comments: "" };
const MAX_ROWS = 10;
Enter fullscreen mode Exit fullscreen mode

Everything stays a string until the last moment. A controlled numeric input that round-trips through Number fights the person typing: "40," becomes 40, "0.5" cannot be typed because "0." is not a number, and a cleared field becomes 0 instead of empty. Parsing happens once, on read:

// "12,500" and "12 500" are both 12500. Empty, zero or nonsense is null.
function parse(s: string): number | null {
  const n = Number(s.replace(/[,\s]/g, ""));
  return Number.isFinite(n) && n > 0 ? n : null;
}
Enter fullscreen mode Exit fullscreen mode

People paste "1,234,567" from YouTube and "12 500" from a locale that groups with spaces. Both have to work, and the easiest way to get them both is to strip separators rather than to guess a locale. Zero is null on purpose: a video with zero views is not a data point, it is an empty field somebody tabbed through.

The median is null until there is something to be median of

Every result in these calculators is a median, not a mean. One video going unusually well is exactly the thing that should not move the number:

function median(xs: number[]): number | null {
  if (!xs.length) return null;
  const s = [...xs].sort((a, b) => a - b);
  const mid = Math.floor(s.length / 2);
  return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
Enter fullscreen mode Exit fullscreen mode

[...xs] because sort mutates and the array came from the component's state. null for an empty array because there is no sensible number for "no videos", and every display path has to handle the empty case anyway.

Null propagates all the way to the screen, and each place it lands says what is missing instead of showing a zero:

<Result
  label="Median engagement rate"
  value={rate === null ? "n/a" : pct(rate)}
  note={rate === null ? "Enter views for at least one video." : `Across ${filled.length} video${filled.length === 1 ? "" : "s"}, ${count(views!)} median views.`}
/>
Enter fullscreen mode Exit fullscreen mode

Instagram has a stricter version of the same idea. Instagram does not show other accounts' view counts on most posts, so views per follower is only computed when enough Reels carry one:

// As in the pipeline: fewer posts with views than this say little.
const MIN_VIEWS = 3;
const medianViews = views.length >= MIN_VIEWS ? median(views) : null;
Enter fullscreen mode Exit fullscreen mode

Two Reels with views and eight posts without do not make a ratio worth printing, so the tile reads "n/a" with the note "Add views for at least 3 Reels".

The accessibility details that a repeated row forces

A row of three fields with labels above them reads fine once. Ten of those is a wall of the same three words. The fields take a compact flag that keeps the label for assistive technology and hides it visually from the sm breakpoint up, where the first row's labels act as column headings:

<Label htmlFor={id} className={cn(compact && "sm:sr-only")}>
  {label}
</Label>
Enter fullscreen mode Exit fullscreen mode

On a phone there are no column headings, because the row becomes a block, so compact does nothing below sm and every field keeps its visible label.

The per-row computed rate is a <p> rather than a field, which means it has no label at all, so it gets one:

<p className="hidden pb-2 text-right text-sm tabular-nums sm:block" aria-label={`Video ${i + 1} engagement rate`}>
  {videos[i] ? pct(videos[i].rate) : "n/a"}
</p>
Enter fullscreen mode Exit fullscreen mode

The results block is wrapped in aria-live="polite", which is the only reason a screen reader user learns that typing in a field changed three numbers further down the page. The remove button is disabled at one row rather than hidden, so the control does not move around under the pointer as rows go.

Every id in the fields comes from useId. There are up to ten rows of three fields on a page, plus the shared field under them, and hand-written ids in a component used four times on four pages are a collision waiting to happen.

Showing the arithmetic

The sponsorship calculator is three small parts in one component: a fee from a CPM, an effective CPM from a quote, and a break-even CPM from the brand's own profit per sale. The useful decision there was to print the sum rather than only the answer:

<Result
  label="Fee"
  value={fee === null ? "n/a" : money(fee)}
  note={fee === null ? "Needs views and a CPM." : `${count(v!)} views ÷ 1,000 × ${money(parse(cpm)!)}`}
/>
Enter fullscreen mode Exit fullscreen mode

Currency is a three-option radio group rather than a select, because three options as buttons are one tap and a select is three:

<div role="radiogroup" aria-label="Currency" className="flex rounded-md border p-0.5">
  {CURRENCIES.map((c) => (
    <button key={c.code} type="button" role="radio" aria-checked={currency.code === c.code} aria-label={c.code} onClick={() => setCurrency(c)}>
      {c.symbol}
    </button>
  ))}
</div>
Enter fullscreen mode Exit fullscreen mode

aria-label={c.code} because the visible content is a symbol: "USD" is a name, "$" is a glyph that three currencies share. Formatting goes through Intl.NumberFormat with the locale that belongs to the currency, and the fraction digits depend on the size of the number, since 2 decimals are useful for a break-even CPM of 15.60 and noise on a fee of 1,200.

The one number I will not publish

Each engagement calculator has a third tile that scores what you typed out of 100, labelled "Nakodo engagement points", with the note "Nakodo's own thresholds, not an industry benchmark". It imports the same curves the product scores creators with, which is the point: a brand reading the number on a public page and a brand reading it inside a campaign are reading the same function.

The curves themselves stay in the app's own module and out of these posts. The calculators give the inputs and the output, the guides explain what a good engagement rate looks like in words, and how it works describes the parts a score is made of. A page that published the whole table would be publishing a rate card for gaming it.

What this shape is good for

Four pages, four components, one record joining them, no server work at request time, and a build that fails if those two lists disagree. The pages are prerendered, so they are as fast as the prose pages around them, and the interactive part is a few kilobytes of state and arithmetic rather than a form posting to anything.

The limit is real though. This only works while the calculators are small enough to live in one file per family of pages. The moment a tool needs data from our database, it stops being a content page with an island in it and becomes an app page that needs a session, which is a different route group, a different privacy story and a different post.

Top comments (0)