posts
8 min read

Two years at a US consulting company building a team effectiveness SaaS platform

Next.jsTypeScripttRPCVercel AI SDKStripePlaywrightFirebase

I spent two years as a full-stack developer at Better Practice, a US-based consulting company building a team effectiveness SaaS platform. This is what I built there, how I worked, and what I learned.

How I got in

Coding always caught my attention but I never had the chance to do it, until a friend of mine who worked there mentioned he was a developer. That was the push. I started studying on Codecademy: HTML, CSS, JavaScript, TypeScript. Eventually I applied, and they gave me the opportunity. For someone with no professional experience as a developer, that chance was everything, and I didn't want to waste it.

Codecademy taught me the basics, but honestly, I learned the most by doing the actual work. It was a huge challenge at the start. I had a lead dev who explained things patiently and taught me how to get unstuck instead of just unblocking me. That mattered more than any course.

This was also before AI coding tools took over, so everything in my first stretch was written by hand. I think that ended up being an advantage. When AI assistance arrived later, I already knew what good code looked like, so I could review it instead of blindly trusting it. More on that below.

The platform

A team effectiveness SaaS: surveys, recurring check-ins, feedback, team goals, learning content, and an AI coaching assistant. One Next.js app serving the marketing site, the logged-in product, and an internal admin area.

The stack I worked in daily: TypeScript end to end, tRPC with TanStack Query for the API layer, Firebase/Firestore for data and auth, Zod for validation, Tailwind CSS with DaisyUI, a headless CMS for content, Stripe for billing, and the Vercel AI SDK for the AI features, hosted on Vercel.

Making the data trustworthy: tRPC, Zod, TanStack Query

As the platform grew we adopted tRPC, Zod and TanStack Query, and adapted fast. The goal was simple: no mismatches between what the client expects and what the server returns. Every input gets validated with Zod before touching the database, so we only write data we know is shaped right, and we fetch data we know is right. Types are inferred from the Zod schemas, so validation and types never drift apart.

The security model follows the same idea. It's the standard layered middleware pattern: procedures act as progressively stricter gates, from "any request" up to "authenticated user with the right role in the right context." Everything sensitive is validated and authorized on the server no matter what the UI allows.

A full UI framework migration, done safely

We migrated the whole app across major versions of Tailwind and DaisyUI, which meant converting a JS-based config to a CSS-based one and rewriting custom themes. This one taught me how to do risky changes safely. We didn't rip everything out at once. We built the new theme foundation first and migrated feature by feature. Nothing hit main until the whole thing was done, but each feature was migrated and tested individually, including a contrast check on every change so we never shipped unreadable text on a bad theme combination.

Gradual migration behind a stable main branch. Boring, careful, and it worked.

Stripe webhooks

I got real exposure to payments here. I built webhook handling for payment events that triggered transactional emails with the billing details, applied conditionally based on the organization's configuration.

I set up Stripe sandboxes to test the whole flow before it ever touched production: trigger the event, verify the handler catches it, check the conditions, send the email. Webhooks are one of those things where you either test them properly or you debug them in production, and I preferred the first option.

Permissions: from access levels to roles

The old system was a number. If your access level was greater than X, you saw the feature. It worked until it didn't: the moment we wanted a specific type of user to access a specific feature, we were stuck doing workarounds in code and piling up tech debt, because access wasn't tied to what a user is, just to a number.

We migrated to a role-based system. Roles have permissions, users have roles. Now if some role needs access to a new feature tomorrow, you add the permission to that role and it's done. No code workaround, no deploy for a permissions change. It's the difference between a system that fights you and one that's built to change.

The AI features

Two separate features here, both built on the Vercel AI SDK.

The coaching chatbot. The platform captures a lot of data from teams, and users struggled to connect the dots on their own. The chatbot explains it in plain language and turns it into actionable insights. My lead dev created the initial chatbot and showed me how it was wired. I built the fullscreen chat UI and, the part I'm most proud of, the tools: the functions that let the model query the database for real team data.

That tooling solved the classic grounding problem every LLM feature runs into. Without access to real data, a model asked something specific will answer anyway, confidently and wrong, or fall back to generic instructions that don't match how your product works. Giving it tools to fetch real data when it decides it's relevant, plus a lot of testing and refining of the system prompt, grounded the answers in what was actually true for that team. Prompt engineering sounds like a buzzword until you've watched it turn a useless bot into a useful one.

AI-suggested goals. Separate feature, same SDK. The model looks at the team's own survey data, finds where they're struggling, and suggests a goal based on that real data. The user clicks add, confirms, and the goal is on their team. From raw numbers to a concrete action in one click.

Scripts and backfills

I wrote maintenance scripts for Firestore: backfills, data updates, migrations. The discipline here was that every script defaults to a dry run and needs an explicit apply flag to actually write. When your script touches production data, you want to see what it would do before it does it.

Testing and CI

I wrote unit tests with Vitest for the logic where correctness really matters, and end-to-end tests with Playwright that drive a real browser through real user flows against a Firebase emulator, with each test seeding its own isolated data.

Nothing merged into any environment without GitHub Actions passing: unit tests, e2e tests, the whole gate. The bar we worked to was that what a story promised gets verified by a browser actually doing it, not just by "we have some tests."

How I work with AI (because it's not what you think)

When AI coding tools became viable we adopted them, but in a specific way, and this is the part I'd defend in any interview.

We never said "do this" and shipped whatever came out. The flow was: share the idea and objectives with the model, ask it to draft a plan, then read that plan and challenge it. Scrutinize the decisions. Only when I'm happy with the plan does it write code.

And that's still not the end. Every change the agent made was reviewed by me, challenged, and sometimes edited by hand, because I noticed agents tend to over-engineer. A lot of times a simple fix is good enough and rock solid, and the model wants to build a cathedral. After my own review pass, the code went through a normal PR review from my lead, then to a preview environment with more data, then through the CI gate.

AI made me faster. The review discipline is what kept the codebase clean.

Bryan learned quality code habits and intuition in a pre-AI world, then adapted to using Claude Code as a force multiplier while acknowledging the shortcomings of AI-generated code. He maintains a high bar for quality code by scrutinizing outputs and asking the right questions.

- Erik, Lead Developer & Product Owner at Better Practice

A normal day

Dev standup. Pick the next refined story from the backlog. Branch off (nothing goes directly to main or preview), build it with the workflow above, write the tests, open the PR, get it reviewed, watch CI go green, ship to preview. Then, on preview, capture proof that the acceptance criteria actually work, a quick recording of the feature doing its thing in a real environment, and attach it for review. Not "trust me, it works," but "watch it work." Merge. Pick the next story. This process was refined over the years until we had what worked well.

What I took from it

I came in self-taught with zero professional dev experience and left having shipped payments, permissions architecture, a production LLM integration, a full UI framework migration, and a testing culture I actually believe in. The biggest lesson wasn't any single technology. It was that careful beats clever: gradual migrations, dry runs, layered auth, reviewing every diff. That's the stuff that lets a small team ship a real product without breaking it.