DEV Community

seller-mind
seller-mind

Posted on

What I Learned Building a Next.js SaaS Boilerplate from Scratch

When I first started building SaaS products, I’d get incredibly excited about the core idea. I’d sketch out the UI, map out the database, and imagine the launch day. But then reality would hit. I’d spend the first three weeks just wiring up authentication, configuring Stripe webhooks, and setting up the basic routing.

By the time I actually started coding the unique features of my app, my motivation was already halfway out the door. I realized I was building the exact same foundational plumbing for every single project. The setup phase was draining my energy before the real work even began. So I decided to build something to fix this.

The Boilerplate Tax

Every SaaS application needs the same core ingredients: user authentication, subscription billing, and a reliable way to serve API requests. But treating these as an afterthought or building them from scratch every time comes with a hidden cost.

Handling Stripe webhooks correctly, for example, is notoriously tricky. If you miss an idempotency check or mess up the signature verification, you end up with duplicate charges or missed subscription renewals. Similarly, setting up OAuth providers securely takes time and attention to detail. When you rush through this "plumbing" to get to the fun stuff, you inevitably ship a fragile foundation. It works fine in development, but breaks in unexpected ways once real users start interacting with it. The cost of ignoring this is delayed launches, technical debt, and eventually, burnout.

Architectural Decisions and Trade-offs

When I started putting together SaaSLaunch, I wanted to use modern tools but keep the architecture pragmatic. I chose Next.js 14 with the App Router for the frontend and API routes, NextAuth.js for authentication, and Stripe for billing. Everything is written in TypeScript to catch errors before they reach production.

One of the biggest architectural decisions I had to make was where to run the heavier API logic. Initially, I tried running everything through standard Vercel serverless functions. While convenient, I noticed latency issues for global users, especially when running the built-in SEO landing page analyzer. Fetching external URLs and parsing HTML from a single region wasn't ideal.

To solve this, I moved the edge-side API routes and the SEO tools to Cloudflare Workers. Running these specific tasks at the edge drastically reduced latency because the code executes in data centers physically closer to the user.

Here is a look at the Next.js API route I use for the Stripe checkout session:

// Next.js API route for Stripe checkout (App Router)
import { NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const { priceId, userId } = await req.json();

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.APP_URL}/dashboard?success=true`,
    cancel_url: `${process.env.APP_URL}/pricing?cancelled=true`,
    client_reference_id: userId,
  });

  return NextResponse.json({ url: session.url });
}
Enter fullscreen mode Exit fullscreen mode

This keeps the checkout flow clean and directly ties the user ID to the Stripe session using client_reference_id, which makes webhook reconciliation much easier later on.

I also had to decide how to handle the SEO analysis tools. Here is the Cloudflare Worker I use for the edge SEO analysis:

// Cloudflare Worker for edge SEO analysis
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname !== '/api/analyze') {
      return new Response('Not found', { status: 404 });
    }

    const targetUrl = url.searchParams.get('url');
    if (!targetUrl) {
      return new Response(JSON.stringify({ error: 'Missing url' }), {
        status: 400,
        headers: { 'Content-Type': 'application/json' },
      });
    }

    // Fetch target page at the edge (low latency worldwide)
    const response = await fetch(targetUrl, {
      cf: { cacheTtl: 300, cacheEverything: true },
    });
    const html = await response.text();

    // Run SEO checks (regex-based, no DOM needed)
    const title = html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1] || '';
    const desc = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)?.[1] || '';
    const h1Count = (html.match(/<h1[ >]/gi) || []).length;

    return new Response(JSON.stringify({ title, desc, h1Count }), {
      headers: { 'Content-Type': 'application/json' },
    });
  },
};
Enter fullscreen mode Exit fullscreen mode

By using the cf object in the fetch request, we can leverage Cloudflare's cache at the edge. The regex-based checks are lightweight and don't require a heavy DOM parsing library, which keeps the Worker execution time well under the millisecond limits.

What Worked and What I'd Change

Looking back at the development of SaaSLaunch, the TypeScript-first approach was a massive time saver. Having full type safety across the Next.js frontend, the Stripe checkout sessions, and the Cloudflare Workers saved me from countless runtime errors. The one-click deployment setup for both Vercel and Cloudflare Pages also removed a huge headache from the initial setup process.

However, I definitely made some mistakes. When I first designed the Stripe integration, I over-engineered the webhook handling. I tried to account for every possible edge case and subscription state transition upfront, which made the code incredibly dense and hard to read. If I were doing it again, I’d start with a simpler state machine and only add complexity as the specific needs of the app dictated.

I also initially made the environment variable setup too rigid. I had a dozen required variables just to get the dev server running, which was overwhelming. I’ve since refactored it so that you only need the core variables to start, and the optional ones can be added later.

Moving Forward

Building a SaaS boilerplate taught me that the foundation of an app is just as important as its unique features. If the plumbing is solid, you can actually enjoy the process of building the product.

If you're currently working on a SaaS project and want to test your landing page's SEO before you launch, I extracted the edge analyzer from the boilerplate into a standalone free tool. You can run your URL through it to check your title tags, meta descriptions, and H1 structure directly from the edge.

You can try the SEO Checker here: https://saaslaunch.dev/free-tools/seo-checker


Full disclosure: I'm the developer of SaaSLaunch. I built this because I needed it myself.

Top comments (0)