I shipped my SaaS dashboard in plain JavaScript. React 19, JSX, no .ts files, no tsconfig.json, no type imports. The marketing site that surrounds it is full TypeScript. The backend is a JavaScript codebase with TypeScript creeping in addtively. Three codebases, three decisions, one indie hacker.
The dashboard is the most opinionated part. Almost every dev.to comment thread I have read on this topic ends in "just use TypeScript, it costs nothing". I disagree, and I want to walk through why for the specific case of a solo-built SaaS dashboard.
This is not an anti-TypeScript post. The marketing site sitting next to the dashboard is TypeScript. The backend is moving to TypeScript file by file. The case I am making is narrower: on a fast-moving, small-team, internal-only React SPA, JavaScript was the right pick, and I would do it again.
What I actually built
To make the rest of this make sense, here is the lay of the land.
-
dashboard/— React 19, Vite, React Router v7, Zustand, Tailwind v4, Radix UI. Plain JavaScript (JSX). About 130 components, 28 tests, notsconfig. -
marketing/— Next.js 16, React 19. Full TypeScript. About 30 pages, MDX blog, SEO-critical. -
backend/— Fastify, Node 20, Postgres. JavaScript with additive TypeScript (utils and middleware migrated, routes still JS).
The dashboard is what users see after they log in. Submissions inbox, form builder, settings, billing, analytics. Internal product surface. The marketing site is the public face: landing, pricing, docs, blog. The backend is the API both call.
The case for TypeScript is strongest on the backend (security, types are contracts) and on the marketing site (long-lived content, multiple contributors potentially). The case is weakest on the dashboard, which is where I drew the line.
Reason 1: I am one developer
The hidden axis on the JavaScript-vs-TypeScript debate is team size.
TypeScript's biggest payoff is on a team of more than three developers. When five people touch the same file in a month, types act as a contract between them. You change a function signature, the type errors light up everywhere it breaks, the team aligns.
On a team of one, that contract is in my head. I changed the function. I know where it is used. The compiler running for 30 seconds to confirm what I already know is a tax I pay every save.
This will change when I hire. I have an escape hatch (Reason 6) for that. Until then, the tax does not earn its keep.
Reason 2: Real shipping bugs are not type bugs
I went through six months of bug reports and self-caught issues across the dashboard. I tagged each one with "TypeScript would have caught this" or "TypeScript would not have caught this".
The breakdown was roughly:
- 8% — type bugs (passed wrong-shaped object, called undefined as a function, missed null)
- 92% — everything else (wrong copy, broken API responses, race conditions, layout breaks, browser quirks, missing edge cases, regex errors, off-by-ones, CORS, CSP, hydration, state machine glitches)
TypeScript prevents the 8%. It does not touch the 92%. The 92% is what actually breaks production. Better tests, better error monitoring, and slowing down to think catch more bugs than the strictest tsconfig.json ever has for me.
Reason 3: The IDE already gives me 80% of TS
Modern VS Code reads JSDoc comments and Radix UI's TypeScript definitions and gives me autocomplete on every prop I touch. I can hover over a component import and see its prop types. I can rename a function and have the language server find usages.
I get this without paying for the build step, the strictness ratchet, or the type-import ceremony at the top of every file.
/**
* @param {{ form: { id: string, name: string }, onArchive: () => void }} props
*/
export function FormCard({ form, onArchive }) {
return <div>{form.name}</div>;
}
That JSDoc block above gives me autocomplete inside the component, lights up errors when I call onArchive with arguments, and never blocks a vite build from succeeding when I am trying to ship a fix at 11 PM.
Reason 4: My API is the source of truth
The dashboard reads JSON from the backend. The shape of that JSON is defined in one place: the backend route handler. If I tighten the dashboard's expectations using TypeScript types, those types are duplicates of the backend's reality, and they will drift.
Solving the drift problem usually means OpenAPI generators, tRPC, or shared package monorepos. Each of those is a separate complexity budget. The shortcut my dashboard uses: small defensive helpers around fetch, and Optional Chaining everywhere I touch data.
// Reading a submission
const email = submission?.data?.email ?? '(no email)';
const submittedAt = new Date(submission?.created_at ?? Date.now());
That two-line pattern handles every shape-drift case I have hit so far. The cost is one ?. per access. The savings is not duplicating 200 backend types into the frontend.
Reason 5: Speed of iteration matters in year one
The dashboard ships a new feature every week or two. Spam blocklist UI, bulk archive, tag filtering, hosted form pages, email reply, plan upgrade flow. Most of those features were specced and shipped within a few days each.
The bottleneck on indie SaaS in year one is not "we ship code with bugs". It is "we are not shipping anything users want". Anything that slows my loop between "user reports a missing feature" and "user sees the missing feature on production" is taxed against my survival.
TypeScript adds ceremony to that loop. Define the type. Pass the type through three layers of components. Re-run tsc when something five files away changed. Fight the never-have-I-ever-seen-this-error generic inference message. I have been through that loop on past projects and I know the cost.
I would rather ship a small bug than block a fix on a refactor of types.
Reason 6: Migration is cheap when I am ready
The decision to skip TypeScript on day one is reversible. Vite supports TypeScript out of the box. I can rename a file from .jsx to .tsx, add // @ts-check to start with checking, and adopt strictness file by file. No big-bang rewrite. The exit ramp is paved.
I have a written trigger for when I do it: second developer hired, or dashboard exceeds 50,000 lines of code, whichever comes first. At that point, the team contract benefit kicks in and the migration cost becomes worth paying.
Until then, the option is preserved without paying for it.
The honest counter-arguments
I want to address the ones I find genuinely strong.
"TS catches refactor bugs." True. I refactor the dashboard maybe once a quarter. The bugs it would have caught I caught via tests and manual QA. On a faster-refactoring codebase, this argument hits harder.
"Library types are valuable." True, and I get them anyway via my IDE. The TypeScript ecosystem produces type definitions whether or not my code is TypeScript. I read Radix UI's types in autocomplete without my project being TypeScript.
"Onboarding new developers." True. This is the strongest single argument, and it is the exact trigger I set for migration. Day one of a second developer is the day TypeScript starts earning its keep.
"It's not really that slow." True at small scale, false at large scale. tsc on a thousand-file project is a real wait. Vite's transpile-only mode hides it during dev, but CI still feels it. Worth noting.
I am not arguing TypeScript is bad. I am arguing it has costs, and those costs were higher than the benefits for my specific situation.
Why the marketing site is TypeScript
For symmetry, here is why the marketing site got the opposite call.
It is Next.js, where TypeScript is the default. Going against the default would be its own tax. The marketing site is a content surface, not a feature surface. It changes slowly, and changes that break it (broken links, missing meta tags) are caught by type checks. There are no API calls to argue about. The dynamic routes are typed against the file system. Cover images, slugs, frontmatter all benefit from types.
Different code, different decision.
What "no TypeScript" actually looks like
For anyone curious about how a serious React app survives without TS, here is the load-bearing tooling.
- JSDoc on shared components and utility functions. Not religiously, but on anything reused.
- PropTypes via Zustand store shapes. The store's shape is documented at the top of each store file.
-
Optional ChainingandNullish Coalescingeverywhere. Cheap insurance against backend drift. -
A small
api.jsthat wraps every fetch. Centralized error handling, response unwrapping, retry logic. No type imports needed. - Vitest tests for anything stateful. Tests cover the surface that types would have covered, plus the surface they would not have.
-
ESLint with
eslint-plugin-react. Catches the easy mistakes TypeScript brags about catching.
The total tooling is not zero. It is just not TypeScript.
The takeaway
If you are a solo developer shipping a SaaS dashboard, JavaScript is a defensible default. If you are on a team of three or more, TypeScript almost certainly pays for itself. If you are anywhere in between, look at your bug reports and ask which percentage is type bugs versus the other 92%.
The default advice on dev.to is to reach for TypeScript automatically. I am not against that default. I am against not questioning it.
The dashboard is in production, used daily by paying customers. The marketing site is TypeScript. The backend is migrating. Three codebases, three decisions, one indie hacker who tries to spend complexity where it actually pays off.
What would change your mind on this tradeoff for your own SaaS?
Top comments (0)