Spend Saturation
Spend vs conversions with a fitted diminishing-returns curve that marks where each extra dollar stops paying off.
Paid acquisition saturation curve
$111
marginal cost / conversion
- Weekly actuals
- Response curve
Returns flatten beyond $14k/wk — the next conversion costs $111 vs $45 at low spend.
Acme marketing · Q1 2026 spend vs qualified conversions · √ response fit
Demo data is illustrative. Replace with your own typed data prop.
How it computes
Least-squares fit of a concave diminishing-returns curve: conversions ≈ a·√spend + b.
u = √spend; a = (n·Σuy − Σu·Σy) / (n·Σu² − (Σu)²); b = (Σy − a·Σu) / nAssumptions
- The relationship is linear in √spend — i.e. returns are concave / diminishing.
- Observations have roughly homogeneous variance.
- Spend is positive.
Honest about
- Non-positive spend is filtered out before fitting.
- If all spend is identical the denominator is 0 and the slope a defaults to 0 (no spurious fit).
- The diminishing-returns "knee" is a heuristic clamped to 4× the minimum observed spend, not an extrapolation past your data.
- Marginal cost-per-conversion clamps at 0 when marginal yield goes negative.
Use it with your data
Data contract
Passed via the data prop — an array of objects: validated at runtime, so a bad shape degrades to the error state rather than crashing.
| Field | Type | Required |
|---|---|---|
| spend | number | yes |
| conversions | number | yes |
Static props
import { MarketingSpendSaturation } from "@/components/blocks/marketing-spend-saturation/marketing-spend-saturation";
<MarketingSpendSaturation data={myData} />Client fetch (SWR)
const { data, isLoading, error } = useSWR("/api/metric", fetcher);
// route isLoading/error through <ChartStates>, then:
<MarketingSpendSaturation data={data} />Server component (RSC)
const data = await fetch(url, {
next: { revalidate: 3600 },
}).then((r) => r.json());
<MarketingSpendSaturation data={data} />Map arbitrary rows
import { mapRows } from "@/lib/sextant/column-map";
const out = mapRows(rows, { /* contractKey: "yourColumn" */ }, dataSchema);
if (out.ok) <MarketingSpendSaturation data={out.data} />;Make it yours
Every customization below is an optional prop — omit them all and you get the defaults shown above. Recolor, reformat, restyle, or drop the card chrome without forking the component.
Recolor or rename a series (fit, actual)
<MarketingSpendSaturation
series={{ fit: { color: "var(--chart-3)", label: "…" } }}
/>Format numbers (currency / locale)
const eur = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
notation: "compact",
});
<MarketingSpendSaturation valueFormatter={(n) => eur.format(n)} />Replace or hide the narrative
<MarketingSpendSaturation narrative="Your own one-liner." />
// …or hide it entirely:
<MarketingSpendSaturation narrative={false} />Restyle any region
<MarketingSpendSaturation
className="max-w-xl"
classNames={{ body: "bg-muted/20" }}
/>Drop the card chrome (headless plot)
import { MarketingSpendSaturationPlot } from "@/components/blocks/marketing-spend-saturation/marketing-spend-saturation";
// Just the chart body — bring your own card/layout:
<MarketingSpendSaturationPlot data={data} config={config} />