DEV Community

Cover image for I Rebuilt the Aquarium Gravel Calculator with Next.js Because Every Fish Site Buries the Math Under SEO Filler
G S
G S

Posted on

I Rebuilt the Aquarium Gravel Calculator with Next.js Because Every Fish Site Buries the Math Under SEO Filler

The Problem: Density Math Hidden Behind 2,000 Words of Filler

I was setting up a 55-gallon planted tank and needed one number: how many pounds of substrate do I need for a 2-inch bed?

Every result on page one made me do one of these:

  • Scroll past a "Top 10 Best Aquarium Substrates 2024" listicle to find an actual input field
  • Use a calculator that only accepted gallons, silently assuming a standard tank shape that didn't match my footprint
  • Get a single flat number with no distinction between sand, gravel, and aqua soil — three materials with wildly different densities that produce wildly different weights for the same depth
  • Watch a sticky video ad cover the result the instant it rendered The actual physics here is genuinely simple: volume times density. It's the kind of calculation that belongs in a 40-line pure function, not underneath an ad-supported content farm.

So I built the Aquarium Substrate Calculator as part of the HypeCalc suite — and this time I want to walk through not just the math, but a genuinely useful architectural pattern I ended up leaning on hard: keeping a Next.js page simultaneously fast for humans, legible to search engines, and completely decoupled from the calculation logic itself.


The Math: Why "Pounds Per Gallon" Is a Bad Rule of Thumb

Most existing calculators just multiply gallons by a flat constant. That's wrong, because density varies enormously by material:

Material Bulk Density Density Factor (lbs/in³)
Fine Sand / Crushed Coral ~100 lbs/cu ft 0.0578
Natural Pea Gravel ~95 lbs/cu ft 0.0550
Planted Tank Aqua Soil ~55 lbs/cu ft 0.0318

Sand packs tighter than gravel (less pore space between grains), and porous baked-clay aqua soil is dramatically lighter than either. A calculator that ignores this and just says "1.5 lbs per gallon" is off by nearly 2x for aqua soil users — which matters when you're ordering $40 bags of the stuff.

The Actual Formula

Instead of gallons, the correct approach works from tank footprint (the part of the physics that's actually invariant):

Substrate Weight (lbs) = Length (in) × Width (in) × Depth (in) × Density Factor (lbs/in³)
Enter fullscreen mode Exit fullscreen mode

And for anyone who wants metric:

Volume (L) = [Length (in) × Width (in) × Depth (in)] / 61.024
Enter fullscreen mode Exit fullscreen mode

Worked Example: A Standard 20-Gallon Tank

A 20-gallon high tank has a 24" × 12" footprint. Target a 2-inch pea gravel bed:

  1. Base area: 24 × 12 = 288 sq in
  2. Total volume: 288 × 2 = 576 cubic inches
  3. Gravel weight: 576 × 0.0550 = 31.68 lbs → round up to two 20-lb bags
  4. Volume in liters: 576 / 61.024 = 9.44 L That's the entire calculation. No 2,000-word preamble required.

The TypeScript Core: Density as Data, Not a Magic Number

Same architectural principle as every calculator in this series — the math lives in a pure, framework-agnostic function, and material density is modeled as data, not hardcoded per-call:

// lib/calculateSubstrate.ts

export type SubstrateMaterial = "sand" | "gravel" | "aquaSoil";

interface MaterialProfile {
  label: string;
  densityLbsPerCubicInch: number;
}

const MATERIAL_DENSITIES: Record<SubstrateMaterial, MaterialProfile> = {
  sand: { label: "Fine Sand / Crushed Coral", densityLbsPerCubicInch: 0.0578 },
  gravel: { label: "Natural Pea Gravel", densityLbsPerCubicInch: 0.055 },
  aquaSoil: { label: "Planted Tank Aqua Soil", densityLbsPerCubicInch: 0.0318 },
};

export interface SubstrateInput {
  lengthInches: number;
  widthInches: number;
  depthInches: number;
  material: SubstrateMaterial;
}

export interface SubstrateResult {
  material: string;
  volumeCubicInches: number;
  volumeLiters: number;
  weightLbs: number;
  weightKg: number;
  estimatedBags: {
    twentyLb: number;
    tenKg: number;
  };
}

const LBS_TO_KG = 0.453592;
const CUBIC_INCHES_TO_LITERS = 61.024;

export function calculateSubstrate({
  lengthInches,
  widthInches,
  depthInches,
  material,
}: SubstrateInput): SubstrateResult {
  if (lengthInches <= 0 || widthInches <= 0 || depthInches <= 0) {
    throw new Error("Tank dimensions must be positive numbers.");
  }

  const profile = MATERIAL_DENSITIES[material];
  const volumeCubicInches = lengthInches * widthInches * depthInches;
  const weightLbs = volumeCubicInches * profile.densityLbsPerCubicInch;
  const volumeLiters = volumeCubicInches / CUBIC_INCHES_TO_LITERS;
  const weightKg = weightLbs * LBS_TO_KG;

  return {
    material: profile.label,
    volumeCubicInches: Number(volumeCubicInches.toFixed(2)),
    volumeLiters: Number(volumeLiters.toFixed(2)),
    weightLbs: Number(weightLbs.toFixed(2)),
    weightKg: Number(weightKg.toFixed(2)),
    estimatedBags: {
      twentyLb: Math.ceil(weightLbs / 20),
      tenKg: Math.ceil(weightKg / 10),
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Modeling MATERIAL_DENSITIES as a lookup table rather than an if/else chain means adding crushed coral for reef tanks or laterite for heavy root feeders later is a one-line addition, not a logic rewrite — and the object shape gives you exhaustiveness checking for free the moment you add a new SubstrateMaterial union member.


The Client Component: Material Switch, Instant Recalc

// components/CalculatorForm.tsx
"use client";

import { useMemo, useState } from "react";
import {
  calculateSubstrate,
  type SubstrateMaterial,
  type SubstrateResult,
} from "@/lib/calculateSubstrate";

const MATERIAL_OPTIONS: { value: SubstrateMaterial; label: string }[] = [
  { value: "sand", label: "Fine Sand" },
  { value: "gravel", label: "Pea Gravel" },
  { value: "aquaSoil", label: "Aqua Soil" },
];

export default function CalculatorForm() {
  const [length, setLength] = useState(24);
  const [width, setWidth] = useState(12);
  const [depth, setDepth] = useState(2);
  const [material, setMaterial] = useState<SubstrateMaterial>("gravel");

  const result: SubstrateResult | null = useMemo(() => {
    if (length <= 0 || width <= 0 || depth <= 0) return null;
    return calculateSubstrate({
      lengthInches: length,
      widthInches: width,
      depthInches: depth,
      material,
    });
  }, [length, width, depth, material]);

  return (
    <div className="space-y-5">
      <div className="grid grid-cols-3 gap-3">
        <NumberField label="Length (in)" value={length} onChange={setLength} />
        <NumberField label="Width (in)" value={width} onChange={setWidth} />
        <NumberField label="Depth (in)" value={depth} onChange={setDepth} />
      </div>

      <label className="flex flex-col gap-1 text-sm font-medium text-zinc-700">
        Material
        <select
          value={material}
          onChange={(e) => setMaterial(e.target.value as SubstrateMaterial)}
          className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 outline-none focus:border-indigo-500"
        >
          {MATERIAL_OPTIONS.map((opt) => (
            <option key={opt.value} value={opt.value}>
              {opt.label}
            </option>
          ))}
        </select>
      </label>

      {result && (
        <div className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 text-sm">
          <Row label="Material" value={result.material} />
          <Row label="Volume" value={`${result.volumeLiters} L`} />
          <Row label="Weight" value={`${result.weightLbs} lbs / ${result.weightKg} kg`} />
          <Row
            label="Estimated Bags"
            value={`${result.estimatedBags.twentyLb} × 20lb bags`}
          />
        </div>
      )}
    </div>
  );
}

function NumberField({
  label,
  value,
  onChange,
}: {
  label: string;
  value: number;
  onChange: (v: number) => void;
}) {
  return (
    <label className="flex flex-col gap-1 text-xs font-medium text-zinc-600">
      {label}
      <input
        type="number"
        value={value}
        onChange={(e) => onChange(Number(e.target.value))}
        className="rounded-lg border border-zinc-300 bg-white px-2 py-2 text-zinc-900 outline-none focus:border-indigo-500"
      />
    </label>
  );
}

function Row({ label, value }: { label: string; value: string }) {
  return (
    <div className="flex items-center justify-between py-1">
      <span className="text-zinc-500">{label}</span>
      <span className="font-mono font-medium text-zinc-900">{value}</span>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Switching material recomputes calculateSubstrate through the same useMemo — no separate effect, no manual re-fetch, no loading state, because there's nothing asynchronous to wait on. Client-side derived state doesn't need machinery borrowed from server-state patterns.


The Underrated Part: Making a Fast Page Findable

Here's the piece that doesn't get talked about enough in "I built a fast calculator" posts: speed alone doesn't win search traffic if the page can't be understood by crawlers and answer engines. So the page ships three JSON-LD schemas alongside the calculator itself, generated at build/request time from the same content that's rendered for humans — not duplicated copy that can drift out of sync:

// page.tsx (excerpt)
const faqSchema = {
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "How many pounds of substrate per gallon of water?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "The general rule of thumb is 1 to 1.5 pounds of substrate per gallon of water for a standard 1.5 to 2-inch bed..."
      }
    }
    // ...
  ]
};

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
/>
Enter fullscreen mode Exit fullscreen mode

A few decisions worth calling out here:

  • The SoftwareApplication schema intentionally omits fake aggregateRating fields. It's tempting to fabricate a 4.9-star rating to win a rich snippet, but that's the kind of structured-data spam Google actively penalizes — and it's just dishonest. The schema only asserts what's true: it's free, it's a utility app, here's what it does.
  • FAQ schema content is a 1:1 match with the visible on-page FAQ section, not a separate hidden payload. Search engines increasingly cross-check that structured data matches rendered content; divergence between the two is a spam signal, and duplicating content also just means two places to keep in sync by hand.
  • Metadata lives in the Next.js Metadata export, not <Head> tags scattered through the component tree, so title/description ownership stays in exactly one place per route. None of this touches Core Web Vitals directly, but it's the difference between "technically fast page" and "page a human actually finds when they search how much gravel do I need for a 55 gallon tank."

Where This Lands on Web Vitals

Same story as the rest of the HypeCalc suite: no ad slots means no layout shift, no third-party scripts means no render-blocking main-thread contention, and derived-state calculation means no server round-trip for a result that's cheap to compute client-side.

Metric Typical Pet/Aquarium Utility Sites HypeCalc
Cumulative Layout Shift 0.2+ (ad units + comment widgets) 0
JS shipped for the interactive tool 500KB–1.5MB Tens of KB
Recalculation on input change Often full page reflow via jQuery Sub-millisecond, memoized
Structured data accuracy Frequently stale/duplicated Generated from the same content tree

Go Poke at It

The calculator, the schema, and the full content layout described above are live: HypeCalc Aquarium Substrate Calculator →

Switch materials, check the Lighthouse score, view source on the JSON-LD if you're curious how it's structured.


Discussion

A few things I'd genuinely like this community's take on:

  1. Where's the real line between "helpful structured data" and "SEO manipulation"? I stripped fake ratings out on principle, but I'm curious how far others think is fair game.
  2. Is co-locating long-form SEO content next to an interactive tool (rather than a pure app-only calculator) a reasonable trade-off, or does it inevitably start creeping back toward the content-farm pattern I was trying to escape?
  3. What's a calculation you've seen buried under unnecessary UI or content bloat that really should just be a clean typed function and an input field?

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.