DEV Community

Cover image for How We Built SaaS Calculators in Next.js (And Kept Them Shareable)
Charlie Brinicombe
Charlie Brinicombe

Posted on • Originally published at trophy.so

How We Built SaaS Calculators in Next.js (And Kept Them Shareable)

When we built our calculator suite, we wanted more than "a form that outputs a number."

We wanted calculators that were:

  • fast and SEO-friendly,
  • easy to extend,
  • mathematically auditable,
  • and shareable via URL.

This post breaks down the architecture, design patterns, and trade-offs behind that implementation.


Motivation (and a bit about Trophy)

Trophy is a product aimed at helping teams make better decisions about growth and retention. In our space, the same few questions come up constantly:

  • What does our churn imply about retention over time?
  • If we reduce churn, what’s the revenue impact over the next 6–12 months?
  • Given ARPU and churn, what’s a reasonable LTV and customer lifespan?

We could have answered those with spreadsheets, PDFs, or one-off blog posts but those formats don’t travel well inside a team. We wanted something that:

  • turns “back-of-napkin” math into an interactive tool,
  • makes assumptions explicit,
  • produces a link you can drop into Slack/Notion, and
  • holds up technically (fast load, predictable state).

That’s why we built calculators as a first-class part of the web app: not just for lead-gen, but as a reusable, composable surface we can iterate on as the product evolves.


Anatomy of a Calculator Page (and why each section exists)

One thing we learned quickly: the calculation itself is only a small part of the user journey.

Each page section has a distinct job:

  • Header + description: establishes context fast ("what this calculator answers") so users know they’re in the right place before entering data.
  • Formula block: adds transparency and trust, especially for technical readers who want to validate assumptions before using outputs.

Header and formula section

  • Input controls: collect the minimum viable assumptions needed to compute a meaningful result without overwhelming the user.
  • Primary result card(s): surface the key answer immediately (e.g. churn, retention, LTV), with lightweight interpretive context.

Primary result section

  • Impact chart section: translates one-off outputs into a forward-looking narrative ("what changes over 3, 6, 12 months"), which is where decision-making usually happens.

Revenue impact section

  • Share section: turns a result into a portable artifact via URL, so teams can discuss the same scenario in Slack/Notion/email.
  • Related calculators: supports natural next questions (e.g. from churn to LTV), increasing usefulness and reducing dead-ends.
  • FAQ + CTA: FAQ handles objections and clarification; CTA gives users a clear next step after insight.

In practice, this structure helped us balance three goals simultaneously: educational content, interactive analysis, and team communication.


Tech Stack at a Glance

  • Next.js App Router for server/client component boundaries
  • React + TypeScript for predictable UI and typed state
  • Tailwind CSS for consistent composable styling
  • Recharts for chart rendering
  • URL query params as the source of truth for shareable results

1) Server-First Page Composition

A key architectural choice: keep route pages as server components, and pass searchParams down.

That gives us:

  • better SSR/SEO behavior,
  • cleaner hydration boundaries,
  • no useSearchParams() at the page level (which can force Suspense/client boundaries).

Example page composition:

import { config } from './config'
import Calculator from './calculator'
import ChurnImpactChart from './churn-impact-chart'
import { CalculatorPage, createCalculatorMetadata } from '@/components/calculators'

export const metadata = createCalculatorMetadata(config)

interface Props {
  searchParams?: Record<string, string | string[] | undefined>
}

export default function ChurnRateCalculatorPage({ searchParams }: Props) {
  return (
    <CalculatorPage
      config={config}
      initialSearchParams={searchParams}
      calculator={<Calculator initialSearchParams={searchParams} />}
      impactChart={<ChurnImpactChart initialSearchParams={searchParams} />}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

This is a clean "orchestration layer" pattern: the page wires dependencies together, while feature components do domain work.


2) URL-Driven State via a Dedicated Hook

Instead of each calculator calling useSearchParams() directly, we introduced one shared adapter hook:

export function useCalculatorStateFromParams(
  initialSearchParams: SearchParamsInput,
): UseCalculatorStateReturn {
  const pathname = usePathname()
  const router = useRouter()
  const params = initialSearchParams ?? {}

  const state = useMemo(() => parseParamsToState(params), [params])

  const setState = useCallback(
    (updates: Partial<CalculatorState>) => {
      const next = { ...params }
      for (const [key, value] of Object.entries(updates)) {
        if (value !== undefined) next[key] = String(value)
      }
      const query = paramsToSearchString(next)
      router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
    },
    [pathname, params, router],
  )

  return useMemo(() => ({ state, setState }), [state, setState])
}
Enter fullscreen mode Exit fullscreen mode

Why this pattern works

  • Single source of truth for query parsing/serialization.
  • No duplicated URL sync logic across calculators.
  • Share-by-default UX: every calculated result can be copied as a link.

This is effectively an adapter around Next routing primitives with a calculator-focused API.


3) Config-Driven Page Metadata and Content

We model each calculator with a typed CalculatorConfig.

export interface CalculatorConfig {
  slug: string
  name: string
  meta: { title: string; description: string }
  formula: string
  content: {
    description: string
    intro?: { title: string; content: string }
    calculatorSectionTitle?: string
    ctaTitle: string
    ctaDescription: string
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Then metadata generation becomes deterministic and reusable:

export function createCalculatorMetadata(config: CalculatorConfig): Metadata {
  return {
    title: config.meta.title,
    description: config.meta.description,
    alternates: {
      canonical: `https://trophy.so/calculators/${config.slug}`,
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

This reduces drift between SEO tags and on-page content, while making it easier to add new calculators safely.


4) Composition Over Monoliths

The shared shell (CalculatorPage) receives three key inputs:

  • config
  • calculator node
  • impactChart node
export default function CalculatorPage({
  config,
  initialSearchParams,
  calculator,
  impactChart,
}: Props) {
  return (
    <CalculatorLayout config={config}>
      <CalculatorShell>{calculator}</CalculatorShell>
      {impactChart}
      <CalculatorShareSection
        config={config}
        initialSearchParams={initialSearchParams}
        hideUntilCalculated
      />
    </CalculatorLayout>
  )
}
Enter fullscreen mode Exit fullscreen mode

This is a pragmatic slot-based composition pattern:

  • The layout remains consistent across calculators.
  • Each domain calculator can evolve independently.
  • Shared behavior (share section, formula, related calculators, CTA) stays centralized.

5) Domain Math Lives in Pure Functions

UI is stateful and interactive; math should be deterministic and testable.

So the formulas sit in standalone utility modules:

export function generateChurnDecayData(
  startingUsers: number,
  period: 1 | 7 | 30,
  churnPercent: number,
  maxDays: number = 90,
) {
  const clamped = Math.max(0, Math.min(100, churnPercent))
  const survivalPerPeriod = clamped >= 100 ? 0 : 1 - clamped / 100
  const points: { days: number; users: number }[] = []
  const step = Math.max(1, Math.floor(period / 2))

  for (let d = 0; d <= maxDays; d += step) {
    const periodsElapsed = d / period
    const survival = Math.pow(survivalPerPeriod, periodsElapsed)
    points.push({ days: d, users: Math.round(startingUsers * survival) })
  }

  return points
}
Enter fullscreen mode Exit fullscreen mode

Benefits

  • easier unit testing,
  • less coupling to React render cycles,
  • clearer auditing for stakeholders who care about formula correctness.

6) Two-Layer Validation Strategy

Each calculator validates in two places:

  1. Realtime input validity (isValid) to disable calculate actions.
  2. Action-time validation inside handleCalculate for safety.
const isValid =
  Number.isFinite(startNum) &&
  Number.isFinite(remainingNum) &&
  startNum > 0 &&
  remainingNum >= 0 &&
  remainingNum <= startNum

const handleCalculate = () => {
  if (!isValid) {
    setResult(null)
    setState({ shareReady: 0 })
    return
  }

  const churn = calculateChurn(start, remaining)
  setResult(churn)
  setState({ startingUsers: start, remainingUsers: remaining, churnRate: churn, shareReady: 1 })
}
Enter fullscreen mode Exit fullscreen mode

This avoids accidental invalid URL state and keeps result cards/charts consistent.


7) Performance and UX Choices

Some implementation details that made a big difference:

  • useMemo for chart data and derived values (half-life, domains).
  • route updates via router.replace with { scroll: false } to avoid janky navigation.
  • fixed, typed period options (1 | 7 | 30) to simplify math and UI consistency.
  • responsive chart containers (overflow-x-auto + minimum widths) for small screens.

8) Design Patterns We Reused Across Calculators

The biggest win was pattern consistency:

  • Template + Slots: one page shell, pluggable calculator + chart.
  • Adapter Hook: one URL-state bridge for all calculators.
  • Pure Domain Modules: math separated from rendering.
  • Config-Driven Metadata: SEO/content generated from typed config.
  • Feature Flags in Query: share readiness and parameterized results.

These patterns made adding new calculators substantially faster after the first one.


9) What We’d Improve Next

If we were extending this further, we’d likely add:

  • schema-based query parsing/validation (e.g. zod/valibot) for stricter runtime guarantees,
  • analytics events at "calculate" and "share" boundaries,
  • optional server-side persistence for comparison history,
  • benchmarking (e.g. “you’re in the 60th percentile for churn in fitness apps”) with clear cohort definitions and data provenance,
  • downloadable reports (PDF/CSV) that bundle inputs, assumptions, charts, and a narrative summary,
  • shareable team reports (invite colleagues, comments, pinned scenarios) so calculators become collaborative artifacts—not just individual tools,
  • scenario management (save multiple parameter sets, compare side-by-side, and track deltas over time),
  • permissions + workspace context so sharing can be public links, private links, or org-only depending on the audience.

Final Thoughts

The core idea is simple: treat calculators as a product surface, not a throwaway widget.

By combining server-first composition, URL-based state, and pure domain math, we ended up with calculators that are:

  • maintainable,
  • predictable,
  • and genuinely useful to share in real workflows.

If you’re building anything similar, start with these two constraints:

  1. Every result should be reproducible from the URL.
  2. Every formula should live outside the component tree.

Everything else gets easier from there.

Top comments (0)