All posts

The Week I Rewrote the Auth Flow, Fought the AI, and Started Thinking Like a Product Person

Today

I want to write this one down properly because it was one of those weeks that actually felt like progress. Not just shipping code, but thinking differently about what I'm building and why.

Let me walk through it.


Why the Auth Flow Was Wrong in the First Place

The old flow was what every tutorial teaches you: sign up, then do the thing. You land on the app, you're asked to create an account, and then, if you survive that, you get to see the actual product.

It's backwards. And I knew it was backwards, but I'd built it that way because it was easier to reason about in code.

The insight that finally pushed me to change it: if someone hasn't seen value yet, why would they give you their email? They have no reason to. You're asking for commitment before you've given them anything. That's not a user flow, that's a hostage situation.

So the new flow is: come in, build your invoice, see the live preview, feel the thing working, then we ask you to sign up. The auth gate is at submit, not at entry. By the time the modal appears asking for a sign-in, the user has already made something they want to send. They're invested. Now they'll give you the email.

After auth, if they haven't completed onboarding, we collect their company details before anything else. No way around it, no skip button. Because without those details, the PDF they just built is useless. It'd have placeholder company info on it.

It took me an embarrassingly long time to land on this. But once I did, the whole flow clicked into place.


The Onboarding Status Architecture (or: How I Almost Made a Very Expensive Mistake)

Okay, so once you have an onboarding flow, you need a way to check: has this user completed it or not?

This sounds trivial. It isn't.

The first option is the obvious one: check the database. On every protected route, in the middleware, call the DB and ask if there's an isOnboarded: true for this user. If yes, let them through. If no, redirect to onboarding.

It works. And it's wrong.

Here's why it's wrong: you'd be hitting the database on every single request from every single user just to verify something that almost never changes. A user goes through onboarding once. After that, you're doing hundreds of reads to confirm something you already know. At scale, that's just burning money and adding latency for no reason.

The better approach is to use Clerk's user metadata. When the user completes onboarding, you write isOnboarded: true into their public metadata. From then on, this gets baked into their JWT. Every subsequent request, you read the metadata from the token, no database roundtrip at all.

There's a wrinkle: the JWT doesn't update instantly after you write to the metadata. There's a brief window where the token still shows the old state. So you need to handle that transition carefully, usually by forcing a token refresh after the metadata write, or by treating the first post-onboarding request as a special case.

Getting this right involved a lot of trial and error. And this is where things got interesting with the AI.


The AI Kept Giving Me the Wrong Answer

I want to talk about this because I think it's something a lot of people are navigating right now and no one really says it out loud.

I was using AI to help debug the metadata flow. Hit a wall. Asked for a simpler approach to get unblocked. And the AI, multiple times across multiple different framings of the question, gave me code that just checked the database in the middleware.

Every single time.

It wasn't hallucinating. The code was correct. It would have worked. But it was architecturally wrong for what I was trying to do, and the AI had no way to know that, because I hadn't explained the full constraint set, and also because the AI doesn't naturally optimise for "minimise unnecessary DB reads." It optimises for "produce code that works."

Those are not the same thing.

This keeps coming up for me with AI tooling. It's incredible for getting unstuck, for boilerplate, for explaining patterns, for rubber ducking. But it doesn't have a sense of what good looks like in the context of your specific system. It doesn't know your scale concerns. It doesn't know what you've already decided and why. It will confidently give you a correct answer that's wrong for your situation.

The implication is uncomfortable: using AI effectively requires already knowing enough to evaluate what it gives you. Which means juniors who offload all thinking to AI are building on sand. And seniors who use it well are compounding their existing knowledge, not replacing it.

I don't think AI is going away. I think the people who learn to use it with clear intent, who know what they're trying to build and why, and treat AI as a fast search engine with code output rather than an architect, are going to be very productive. The people who just prompt and paste are going to struggle when things get weird.

This week was a reminder that I need to keep the thinking in my head, not outsource it entirely.


The Bugs That Were Quietly Breaking the PDFs

While I was fixing the auth flow, I caught a cluster of bugs that had been silently corrupting the PDF output:

user was undefined in ModernInvoice.tsx. The root cause was that InvoicePDFButton wasn't passing user down to InvoicePDFDialog. So the component that actually rendered the PDF had no user context, and therefore no company details to show. Classic prop drilling miss, the kind that's invisible until you look at the actual output.

A hardcoded "Your Company" string. Left over from early dev when I was just testing the PDF layout. It had been sitting there the whole time, overriding real data with a placeholder. Every invoice PDF had "Your Company" on it no matter what. I feel some type of way about this one.

Type mismatch between InvoiceData and InvoiceForPDF. The local InvoiceData type in InvoicePDFButton was missing customerId, which caused a type error when passed to the PDF generation function. Fix was simple: drop the local type and just import and reuse InvoiceForPDF directly. One source of truth.

None of these were interesting bugs to fix. But together they meant the PDF output was broken in a way that wasn't obvious until you actually read the generated file carefully. Which, again: you need to actually look at your output.


The Product Thinking Phase

Somewhere in the middle of all this, I noticed something shift.

I stopped thinking primarily in code and started thinking more in user flows. Not "how do I implement this" but "should this even exist" and "what does the user actually need right now."

The create invoice page is a good example. I spent a while sitting with the question of whether to use a stepper or a split-panel layout. Steppers feel clean and structured. But invoice fields are deeply interdependent: your line items affect the total, your currency affects formatting, your due date affects payment terms, your client's details affect the header layout. A stepper hides these relationships. A split panel with the form on the left and a live PDF preview on the right makes the dependencies visible and immediate.

The split panel won. Not because it's technically easier (it isn't; debouncing the PDF renderer at ~400ms so it feels responsive without thrashing is its own little project). But because it's more honest about how invoices actually work.

I've been thinking a lot about the idea that simplicity isn't the absence of features, it's the right things in the right order. Every UI decision should be: what does the user need right now, and what are we making them deal with that they shouldn't have to?

The template picker is another example. I decided it should be a modal that opens on "Create Invoice" click, not a page, not a step, not a sidebar. Each template card will have a "Preview sample" link that opens a fullscreen overlay so you can actually see what the template looks like with real invoice data before you commit to it. Once you pick, you land in the builder with it applied and a small pill in the header to switch templates if you change your mind, without losing your form data.

Small decisions. But when you get them right, the product feels like it was designed by someone who thought about it. When you get them wrong, it feels like features were bolted on.

A YouTube channel that's been really useful for developing this kind of thinking: Kole Jain's channel. He talks about product philosophy and UI decisions in a way that's actually specific and useful rather than generic "make it simple" advice.


What I Didn't Build (and Why That's Also Progress)

I want to call out one decision I'm genuinely proud of: I didn't add logo upload.

The onboarding form captures company name, address, and the other basic details you'd want on an invoice header. No logo. Because adding logo upload would mean setting up S3 or Uploadthing, which is its own project with its own failure modes and its own debugging session.

Six months ago I would have either added it half-baked or blocked myself on it for a week before giving up. This time I just... didn't build it. Noted it as a future thing. Moved on.

Knowing what to not build right now is a skill. I'm slowly getting it.


What's Coming Next

The split-panel invoice builder is next. Live PDF preview with debouncing, template switcher in the header, auto-save to localStorage every 30 seconds so you don't lose work if something goes wrong.

The success state on submit: button morphs to a checkmark, then a confetti animation with canvas-confetti, then redirect to the invoice detail page. Small thing but it matters. The moment of "I sent the invoice" should feel good.

And eventually, logo upload. When I'm ready for it.


If you've read this far, thank you. Writing these posts is partly for accountability and partly because working through it in prose helps me think more clearly than staring at code.

If you're building something and hitting the "AI gives me working but wrong code" problem, I genuinely want to hear how you're navigating it. Drop a comment or reach me on dev.to.

The dev.to version of this post is here if you want a shorter cut.