---
title: How We Built Production-Ready SaaS Calculators in Next.js (And Kept Them Shareable)
canonical_url: "https://trophy.so/blog/how-we-built-our-shareable-calculators"
description: "We built calculators based on common questions we hear from customers with an emphasis on shareability, performance and auditability. Here's how we did it."
last_updated: "2026-07-23T10:22:53.000+00:00"
---

# How We Built Production-Ready SaaS Calculators in Next.js (And Kept Them Shareable)

When we built our [calculator suite](https://trophy.so/calculators), 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](https://trophy.so) is an infrastructure platform for web and mobile apps to add gamification features. For us, we hear the same questions come up constantly from customers:

-   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, SEO-friendly, 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.

![](https://storage.ghost.io/c/19/20/1920b913-769b-42d4-8aea-7e26af22b8ee/content/images/2026/03/image-52.png)

Header and formula block

-   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.

![](https://storage.ghost.io/c/19/20/1920b913-769b-42d4-8aea-7e26af22b8ee/content/images/2026/03/584_1x_shots_so.png)

Primary result card

-   Revenue 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.

![](https://storage.ghost.io/c/19/20/1920b913-769b-42d4-8aea-7e26af22b8ee/content/images/2026/03/image-53.png)

Revenue impact analysis

-   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} />}
    />
  )
}
```

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])
}
```

### 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
  }
  // ...
}
```

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}`,
    },
  }
}
```

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>
  )
}
```

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
}
```

### 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 })
}
```

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

* * *

## 7) URL-Based Sharing With a Readiness Flag

We only show sharing controls when a valid result exists.

That state is encoded in the URL with `shareReady=1`.

```
const shareReady = state.shareReady
const hasResult = Number(shareReady) === 1 || String(shareReady) === '1'

if (hideUntilCalculated && !hasResult) return null
```

Share URL generation is deterministic from pathname + query string:

```
function buildShareUrl(pathname: string, search: string): string {
  const base = process.env.NEXT_PUBLIC_SITE_URL || 'https://trophy.so'
  return `${base}${search ? `${pathname}?${search}` : pathname}`
}
```

This gives us low-friction sharing while preserving app state exactly as calculated.

* * *

## 8) 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.

* * *

## 9) 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.

* * *

## 10) What We’d Improve Next

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

-   unit tests for all formula utilities,
-   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 for fitness mobile 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,
-   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.

We'll assess what to build next based on the traffic and usage patterns we see.

* * *

## 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.
