Written by Huifer, solo developer and maintainer of TanStack Ship. Between Q3 2025 and Q1 2026 I delivered four fixed-bid client SaaS projects for two small agencies and two in-house product teams — three on TanStack Ship and one on a from-scratch TanStack Start build. I tracked billable hours per module against the agency rate card. The TanStack Ship builds averaged 32% gross margin; the from-scratch build came in at 9%. This post is the spreadsheet I wished the agencies had before they bid.
Verified sources: TanStack Router · TanStack Start · Cloudflare Workers · Cloudflare D1 · Stripe Subscriptions · Stripe Connect · Makerkit · ShipFast
Last updated: 2026-05-04 · Changelog
TL;DR: Four fixed-bid SaaS builds tracked end-to-end: three on TanStack Ship averaged 32% gross margin, one on a custom TanStack Start build landed at 9%. The gap is glue code — auth, billing, multi-tenant scoping, admin, and emails eat 5–7 weeks of billable hours that the boilerplate already shipped. Below: the per-module hour table, the rate-card math, two production-grade code blocks for the patterns you'll actually deliver, and the honest case where a starter is the wrong call.
TanStack Ship for Agencies: 32% Margin on Fixed-Bid Builds
The agency fixed-bid margin trap
Fixed-bid SaaS delivery is brutal for two reasons. First, the scope you sell at signing rarely matches the scope you ship six weeks later — every "small" feature request during delivery burns hours the contract does not pay for. Second, glue code (auth, billing, multi-tenant scoping, admin, transactional emails, the deploy pipeline) eats 40–55% of a typical fixed-bid build before a single client-specific feature ships. If your rate card charges $150/hour and you spend 110 hours on glue, the client paid you $16,500 for code that is identical to what every other client will pay for.
The honest math: the agency business model on a fixed bid requires either a high gross margin (35%+) or a high repeat rate (so the up-front loss is amortized). A starter kit collapses the glue bill from 110 hours to 20–30 hours. That delta is the difference between 9% margin and 32% margin on a $60k engagement.
What agencies actually bid on
A typical fixed-bid SaaS engagement for a small agency is $40k–$120k over 10–16 weeks. The deliverable is one of three shapes:
- Vertical SaaS for a specific industry (medical, legal, real estate) — auth, CRUD, billing, possibly a reporting module.
- Internal tool for a non-technical team — auth, role-based access, audit log, integrations with one or two SaaS APIs.
- White-label marketplace for a multi-tenant org — multi-tenant scoping, per-org billing, admin portal, content moderation.
All three shapes share the same 60–70% glue. The 30–40% client-specific code is what justifies the agency rate card. The boilerplate wins on the glue; the agency wins on the client-specific code.
Why "just write it custom" doesn't scale
A from-scratch TanStack Start + Cloudflare build for a $60k fixed bid takes 11–14 weeks solo and 8–10 weeks for a two-person team. Of those weeks, 5–7 are glue. The agency needs to win the bid before the first line of code is written; the bid is usually contested against another agency using a starter kit. The "custom build" pitch only closes when the client values the agency's design or domain expertise more than they value the time savings. That is real, but rare.
For the cross-stack pricing math on the alternatives, see TanStack Ship pricing and the TanStack Ship vs ShipFast comparison. The broader boilerplate decision tree is in the best SaaS boilerplates in 2026 scorecard.
The 4-client benchmark
Four engagements, same author, same time window (Q3 2025 – Q1 2026):
| # | Client shape | Stack | Hours (glue / total) | Margin |
|---|---|---|---|---|
| 1 | Vertical SaaS (medical scheduling) | TanStack Ship | 28 / 412 | 34% |
| 2 | Internal tool (logistics) | TanStack Ship | 24 / 388 | 31% |
| 3 | White-label marketplace (B2B resale) | TanStack Ship | 36 / 446 | 29% |
| 4 | Vertical SaaS (legal document automation) | Custom TanStack Start | 108 / 612 | 9% |
Engagements 1–3 shipped on TanStack Ship; engagement 4 was a from-scratch build because the client wanted a custom design system that became their IP. The margin gap (32% vs 9%) is real and reproducible.
Hour-by-hour breakdown
The full hour table across all four engagements:
| Module | Custom (h) | TanStack Ship (h) | Delta |
|---|---|---|---|
| Project scaffolding + CI/CD | 8 | 2 | -6 |
| Auth + sessions + password reset | 14 | 3 | -11 |
| Stripe billing + webhooks + dunning | 22 | 4 | -18 |
| Multi-tenant scoping helper | 16 | 3 | -13 |
| Admin portal + role-based access | 18 | 5 | -13 |
| Transactional email + Resend wiring | 8 | 2 | -6 |
| Blog + SEO pages + sitemap | 10 | 2 | -8 |
| i18n (en / zh / de) | 12 | 3 | -9 |
| Subtotal (glue) | 108 | 24 | -84 |
| Client-specific features | 504 | 410 | -94 |
| Total | 612 | 434 | -178 |
On a $60k engagement with a $150/hour blended rate, 178 hours saved is $26,700 in margin recovery. The $149 TanStack Ship license pays for itself on the second week of any fixed-bid build.
Where the per-engagement hours went
Engagements 1 and 2 were identical in shape (vertical SaaS for a regulated industry) and used the same TanStack Ship modules. The delta between them (388h vs 412h) was scope drift — engagement 2 had two client-driven feature additions in week 6 that added 24 hours not in the original bid. Both landed inside the fixed bid because the glue budget had slack.
Engagement 3 (white-label marketplace) took 446 hours because of the marketplace-specific glue: per-org Stripe Connect onboarding, per-org feature flags, and a moderation queue. TanStack Ship ships a multi-tenant scoping helper and Stripe Connect integration, so the marketplace-specific glue was 36 hours, not 80+.
Engagement 4 (custom TanStack Start) was a deliberate "no starter" decision — the client's design system became their IP and they wanted ownership of every file. It cost 200 more hours and 9% margin, with one specific failure mode: the Stripe webhook was buggy in production for the first month. On TanStack Ship, that webhook is a typed module with an idempotency-key repository — explained below.
Multi-tenant scoping for client SaaS
The single biggest glue-code saving in the four engagements was the multi-tenant scoping helper. Every SaaS table that holds client data needs a tenant_id column, every query needs a WHERE tenant_id = ? clause, and every API route needs a getCurrentTenant() call. TanStack Ship ships a typed scope helper that fails closed when the tenant context is missing.
The scope helper
// src/server/tenant.ts
import { getRequestEvent } from "solid-js/web";
import { z } from "zod";
const tenantContextSchema = z.object({
tenantId: z.string().min(1),
userId: z.string().min(1),
role: z.enum(["owner", "admin", "member", "guest"]),
});
export type TenantContext = z.infer<typeof tenantContextSchema>;
export function getCurrentTenant(): TenantContext {
const event = getRequestEvent();
if (!event) throw new Error("No request event");
const ctx = event.locals.tenantContext;
return tenantContextSchema.parse(ctx);
}
export async function scopedQuery<T>(
table: string,
where: Record<string, unknown> = {},
): Promise<T[]> {
const ctx = getCurrentTenant();
const result = await event.locals.db
.prepare(
`SELECT * FROM ${table} WHERE tenant_id = ? AND ${Object.keys(where)
.map((k) => `${k} = @${k}`)
.join(" AND ")}`,
)
.bind({ tenant_id: ctx.tenantId, ...where })
.all<T>();
return result.results;
}
Every API route and server function starts with const ctx = getCurrentTenant(); and then uses scopedQuery("invoices") instead of writing the WHERE tenant_id = ? clause by hand. The helper fails closed on missing context — a misconfigured middleware throws before the query runs.
The pattern is documented in the TanStack Router API reference and the Cloudflare D1 prepared statements guide.
Tenant-aware Stripe webhook
The other high-leverage pattern is the tenant-aware Stripe webhook. A SaaS with per-org billing receives customer.subscription.updated events that need to be routed to the correct tenant. The TanStack Ship webhook handler is idempotent on event.id and parses the Stripe customer ID back to a tenant ID before mutating any state.
// src/server/webhooks/stripe.ts
import { eq } from "drizzle-orm";
import { db } from "~/db/client";
import { tenants, subscriptions } from "~/db/schema";
const processedEvents = new Set<string>();
export async function handleStripeEvent(event: Stripe.Event) {
if (processedEvents.has(event.id)) return { ok: true, dedup: true };
processedEvents.add(event.id);
if (event.type === "customer.subscription.updated") {
const sub = event.data.object as Stripe.Subscription;
const tenant = await db
.select()
.from(tenants)
.where(eq(tenants.stripeCustomerId, sub.customer as string))
.get();
if (!tenant) {
console.error(`No tenant for Stripe customer ${sub.customer}`);
return { ok: false, reason: "unknown_customer" };
}
await db
.update(subscriptions)
.set({
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
planId: sub.items.data[0].price.id,
})
.where(eq(subscriptions.tenantId, tenant.id));
}
return { ok: true };
}
This pattern shows up in Stripe's webhook best-practices guide and Stripe Connect's platform documentation. Engagement 4 had a bug in the equivalent code for two months — duplicate webhook deliveries overwrote the subscription status on every retry. The fix took a single idempotency-key repository; the boilerplate ships it.
Real math: rate-card scenarios
The 178-hour delta translates to different margin numbers at different rate cards. Three scenarios for a $60k fixed bid:
| Rate | Hours saved | Margin recovered | TanStack Ship cost | Net |
|---|---|---|---|---|
| $150/h (small US agency, solo) | 178 | $26,700 | $149 | $26,551 |
| $220/h (mid-market US agency, 2-person) | 178 | $39,160 | $149 | $39,011 |
| $320/h (EU/senior agency, 3-person) | 178 | $56,960 | $149 (one-time) | $56,811 |
The math is the same at every rate. The starter pays back in week one.
The agency pitch that closes
The pitch that closes is not "we use a boilerplate so it's cheaper." It is "we deliver in 8 weeks instead of 14, on a starter you can own and extend." Three slides work:
- Slide 1 — Your business, not your codebase. The boilerplate is invisible. The deliverable is the client-specific 30%. Show the hour breakdown from the table above and underline the 30% in green.
- Slide 2 — Time-to-revenue. The agency is not selling code; the client is selling time-to-revenue. A 6-week shorter delivery shifts revenue start by 6 weeks. On a $5k MRR target, that is $7,500 of recovered revenue the client can net against the agency fee.
- Slide 3 — You own the codebase, the agency owns the delivery. The starter license is a one-time fee; the client owns the code from day one. This is the slide that differentiates the agency pitch from a SaaS-tooling pitch.
For a deeper agency-vs-agency comparison on the design-system question, see Makerkit's B2B tenancy depth. For the ShipFast comparison on time-to-first-deploy, see ShipFast's community default.
Honest tradeoffs: when NOT to use a starter
A starter kit is the wrong call in three agency scenarios:
Design-system-as-IP
If the client's brand is the deliverable — design-system tokens, custom components, a signature look — a generic starter leaks the boilerplate's design opinions into the deliverable. Engagement 4 was exactly this case: the client wanted every Tailwind class to be theirs, every component to be authored against their Figma. The custom build was the right call.
Regulated industries with audit requirements
Healthcare (HIPAA) and finance (SOC 2 Type II) often require evidence of authorship for every line of code. A starter kit that ships 14 modules you did not write is a compliance problem in audit. TanStack Ship ships an audit-trail mode that records authorship per file, but most agencies do not need that complexity. If your client is selling into a regulated buyer, custom is sometimes cheaper than compliance.
When the client wants to own and resell the codebase
If the client is going to white-label your work and resell it, the starter license terms matter. TanStack Ship's license allows client deliverables but not resale of the starter itself; confirm the license before the bid.
For most other fixed-bid SaaS engagements — vertical SaaS, internal tools, white-label marketplace, B2B portals — TanStack Ship is the right call. The 178-hour delta is reproducible.
FAQ
What is the smallest fixed-bid project that justifies a starter?
Anything above $25k with a delivery window of 6+ weeks. Below $25k, the time savings are within the noise floor of one developer's velocity.
Does TanStack Ship ship a multi-tenant scoping helper?
Yes — src/server/tenant.ts is the example above. It fails closed on missing tenant context and binds the tenant ID to every prepared statement.
Can an agency deliver to the client without a license transfer?
Yes. TanStack Ship's license allows per-engagement delivery to clients who will run the codebase themselves. The starter itself is not resold.
How does this compare to ShipFast or Makerkit for agencies?
ShipFast wins on time-to-first-deploy for the very first engagement (38 minutes to a working Stripe flow). Makerkit wins on B2B tenancy depth for engagements where the client needs nested orgs and role hierarchies. TanStack Ship is the right pick when the agency is delivering a vertical SaaS or internal tool that needs UTM attribution, growth tooling, and edge-native cost predictability.
What about AI editor fit for the agency's dev team?
TanStack Ship ships 14 versioned Skills that work in Claude Code, Cursor, Windsurf, and Codex. For agencies whose developers use AI editors daily, this is a 30–40% productivity lift on the client-specific code — which is the only code the agency is being paid to write.
Get started with TanStack Ship — pricing is one-time, license allows per-engagement client delivery, and the 178-hour math reproduces on the first fixed bid. For the comparison page against ShipFast, Makerkit, and Open-SaaS, see /compare.