Building a Signals-First Angular 21 SaaS Boilerplate: What I Learned

typescript dev.to

I have started the same project three times in the past year. Different clients, different SaaS ideas, same setup work every single time. Auth, roles, billing, audit logging, SSR. Hours have gone by before I write a single line of actual product code. So I stopped. I spent a few weeks building a proper production-ready Angular 21 boilerplate, and in the process learned more about signals, NgRx, and SSR than I had in the previous two years combined.

Here is what I actually learned:

The "signals-first" trap

Everyone talks about going signals-first like it is a single decision you make at the start of a project. It is not. It is about 40 small decisions you make across the entire codebase, and if you are not consistent, you end up with a mess that is half signals, half decorators, and fully confusing.

My rule: zero @Input() or @Output() decorators anywhere. Every component uses input(), output(), and viewChild(). Templates use @if and @for exclusively. No exceptions. The moment you let one @Input() decorator through because it seems easier at the time, it multiplies. You end up with team members (or your future self) not knowing which pattern to follow.

NgRx 21 for global state, local signals for UI state.

This was the decision I went back and forth on the most. Signals can handle global state too, especially with the NgRx Signal Store. So why NgRx? Honestly, it is a preference call. I went with NgRx with createFeature and createActionGroup because I wanted Redux DevTools, time-travel debugging, and a pattern that scales predictably on a team. If you are starting fresh with no NgRx history, going pure signals with Signal Store is probably the cleaner choice.

What I landed on: NgRx for anything that crosses component boundaries or needs to survive navigation. Auth state, billing state, user state. Then toSignal() in components to consume it, so templates stay signal-consistent.

For component-level UI state, sidebar collapsed, modal open, tab selected, plain signal() is all you need. Pulling that into NgRx is over-engineering and it adds noise to your Redux DevTools.
The line is simple: if only one component cares about the state, keep it local. If two or more components or routes need it, it goes into the store.

provideZonelessChangeDetection() is not optional anymore

I added provideZonelessChangeDetection(), and the app immediately felt snappier. Not dramatically, but noticeably. Scrolling smoother, fewer unnecessary re-renders.

The catch: you need to be fully on signals or observables with the async pipe. If you have anything relying on Zone.js to trigger change detection automatically, it will silently break. Debug that before going zoneless, not after.

SSR hydration was the biggest pain

Angular's @angular/ssr has come a long way, but hydration mismatches are still the thing that will cost you the most time.
The mistakes I made:

Accessing the window or document directly in services without checking for the browser platform first. Initialising state in a constructor that runs on the server and produces different values on the client. Third-party libraries that assume a browser environment

The fix for all three is the same: isPlatformBrowser() and being very deliberate about where you initialise things. Put browser-only code in afterNextRender() or wrap it with a platform check.

Stripe webhooks with Angular SSR

This one is worth its own section because there is almost no good content about it.

Stripe sends webhook events to your server. With Angular SSR running through Express, you need to handle the raw body carefully because signature verification requires the raw unparsed buffer, not the parsed JSON.

The key is to add a raw body parser specifically for your webhook route before the general body parser runs:


server.post('/api/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'];
    const event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env['STRIPE_WEBHOOK_SECRET']
    );
    // handle event
  }
);
Enter fullscreen mode Exit fullscreen mode

Get that order wrong, and you will spend hours wondering why signature verification keeps failing.

The role system no one talks about properly

Most tutorials show you a role system with two roles: admin and user. Real SaaS products have three or four, and the permission checks are scattered everywhere.
What works: a single PermissionService that takes a required permission and returns a signal. Components inject it, templates consume the signal, and you never write role-checking logic in a template directly.

canManageUsers = this.permissions.can('users:manage');
Enter fullscreen mode Exit fullscreen mode

That is it. One place to change when your role requirements change.

Audit logging that actually works

Most implementations write audit logs from the application layer. The problem is that anything that bypasses your API, direct DB access, migrations, and admin scripts never gets logged.

The better approach: write audit logs at the database layer. In Supabase, that means a trigger on the tables you care about, writing to an insert-only audit table where the RLS policy only allows INSERT, never UPDATE or DELETE.

Your audit log becomes tamper-proof by design.

The AGENT.md file I wish I had earlier

One thing I added that I had never done before: an AGENT.md file in the root of the project. It documents the architecture decisions, naming conventions, what state goes where, and what the coding agent should never do autonomously. When I pick up the project after two weeks away, or when I am using an AI coding tool to scaffold something new, the context is right there.

It sounds like documentation nobody reads, but when your coding agent starts making decisions you did not intend, you will want it.

What I ended up with

After all of this, I packaged everything into a starter kit called devkitly. Angular 21, NgRx 21, Supabase, Stripe, Tailwind v4, SSR out of the box. All the decisions above have already been made and wired together.

If you want to see how all these patterns work together in a real codebase, the demo is at devkitly.io.

I kept the source structure readable on purpose because I hate boilerplates where you cannot figure out what is going on.

Happy to answer questions about any of the architecture decisions in the comments.

Read Full Tutorial open_in_new
arrow_back Back to Tutorials