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³)
And for anyone who wants metric:
Volume (L) = [Length (in) × Width (in) × Depth (in)] / 61.024
Worked Example: A Standard 20-Gallon Tank
A 20-gallon high tank has a 24" × 12" footprint. Target a 2-inch pea gravel bed:
- Base area: 24 × 12 = 288 sq in
- Total volume: 288 × 2 = 576 cubic inches
- Gravel weight: 576 × 0.0550 = 31.68 lbs → round up to two 20-lb bags
- 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),
},
};
}
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>
);
}
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) }}
/>
A few decisions worth calling out here:
-
The
SoftwareApplicationschema intentionally omits fakeaggregateRatingfields. 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
Metadataexport, 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:
- 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.
- 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?
- 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.