Skene
BLOG

Saasfly Boilerplate Review: PLG Audit 2026

Is Saasfly good for product-led growth? We audited signup, feature gating, analytics, and billing. Score: 4/10 for PLG. Has PostHog and Stripe, but feature limits aren't enforced — code is commented out.

·bySkene·LinkedIn
Summarize this article with LLMs

Saasfly is an open-source Next.js boilerplate with 2,800+ GitHub stars, positioning itself as "you don't need to buy templates anymore." It uses a modern monorepo structure with Turborepo, tRPC, Prisma, and Stripe.

We ran it through a comprehensive PLG skills audit — analyzing signup flow CRO, feature gating, trial optimization, product analytics, self-serve motion, and paywall design.

Overall PLG score: 4/10 — PostHog analytics and full Stripe checkout are implemented, but the critical flaw is that feature limits exist in the UI but aren't enforced in code — the gating logic is commented out.


The full audit

CategoryScoreKey finding
Signup flow7/10Magic link + GitHub OAuth, multi-language
Feature gating2/10Plan limits defined but NOT enforced
Trial optimization0/10No trial system implemented
Product analytics3/10PostHog integrated, pageviews only
Self-serve motion8/10Full Stripe checkout + billing portal
Paywall/upgrade CRO4/10Clean pricing page, but no in-app prompts

1. Signup flow analysis

What's implemented

Magic link authentication with differentiated emails for new vs existing users:

// packages/auth/nextauth.ts
EmailProvider({
  sendVerificationRequest: async ({ identifier, url }) => {
    const user = await db
      .selectFrom("User")
      .select(["name", "emailVerified"])
      .where("email", "=", identifier)
      .executeTakeFirst();

    const userVerified = !!user?.emailVerified;
    const authSubject = userVerified
      ? `Sign-in link for ${siteConfig.name}`
      : "Activate your account";

Simple auth form with email-only field:

// apps/nextjs/src/components/user-auth-form.tsx
const userAuthSchema = z.object({
  email: z.string().email(),
});

Form fields: 1 (email only for magic link)

Authentication options:

  • Magic link (email)
  • GitHub OAuth

Multi-language support: English, Japanese, Korean, Chinese

What's missing

  • No password-based authentication
  • No Google OAuth (only GitHub)
  • No signup analytics tracking
  • No progressive profiling

2. Feature gating audit

What's implemented

Three subscription tiers defined in the database:

// packages/db/prisma/schema.prisma
enum SubscriptionPlan {
  FREE
  PRO
  BUSINESS
}

model Customer {
  plan  SubscriptionPlan?
}

model K8sClusterConfig {
  plan  SubscriptionPlan? @default(FREE)
}

Pricing data with clear feature limits:

// apps/nextjs/src/config/price/price-data.ts
{
  id: "starter",
  benefits: [
    "Up to 1 cluster per month",    // Limit defined
    "Basic analytics and reporting",
  ],
},
{
  id: "pro",
  benefits: [
    "Up to 3 clusters per month",   // Higher limit
    "Advanced analytics",
  ],
},
{
  id: "business",
  benefits: [
    "Up to 10 clusters per month",  // Highest limit
  ],
}

The critical problem

Feature limits are NOT enforced. Look at the cluster creation code:

// packages/api/src/router/k8s.ts
createCluster: protectedProcedure
  .input(k8sClusterCreateSchema)
  .mutation(async ({ ctx, input }) => {
    // Creates cluster WITHOUT checking plan limits
    const newCluster = await db
      .insertInto("K8sClusterConfig")
      .values({
        name: input.name,
        plan: SubscriptionPlan.FREE,  // Always FREE, no limit check
        authUserId: userId,
      })
      .returning("id")
      .executeTakeFirst();

    return { id: newCluster.id, success: true };
  }),

And the UI upgrade prompt is commented out:

// apps/nextjs/src/components/k8s/cluster-create-button.tsx
async function onClick() {
  const res = await trpc.k8s.createCluster.mutate({...});

  // COMMENTED OUT:
  // if (response.status === 402) {
  //   return toast({
  //     title: "Limit of 1 cluster reached.",
  //     description: "Please upgrade to the PROD plan.",
  //     variant: "destructive",
  //   });
  // }
}

Impact: A FREE user can create unlimited clusters. The pricing page promises limits that don't exist.


3. Trial optimization

What's implemented

Nothing.

What's missing

Trials are completely absent:

  • No trial_ends_at field in Customer model
  • No trial period in Stripe checkout
  • No trial expiration emails
  • No trial countdown UI
  • FREE plan exists but isn't positioned as a trial

4. Product analytics

What's implemented

PostHog SDK with automatic pageview tracking:

// apps/nextjs/src/config/providers.tsx
import posthog from "posthog-js";

export function PostHogPageview() {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  useEffect(() => {
    if (pathname) {
      let url = window.origin + pathname;
      if (searchParams?.toString()) {
        url = url + `?${searchParams.toString()}`;
      }
      posthog.capture("$pageview", {
        $current_url: url,
      });
    }
  }, [pathname, searchParams]);
}

Environment variables configured:

  • NEXT_PUBLIC_POSTHOG_KEY
  • NEXT_PUBLIC_POSTHOG_HOST

What's missing

Only pageviews are tracked. Searching the entire codebase:

grep -r "posthog.capture" --include="*.ts" --include="*.tsx"
# Returns: 1 result (the pageview above)

No custom events for:

  • Signup completed
  • Cluster creation
  • Upgrade started
  • Payment completed
  • Feature usage

5. Self-serve motion

What's implemented

Full Stripe checkout flow:

// packages/api/src/router/stripe.ts
createSession: protectedProcedure
  .input(z.object({ planId: z.string() }))
  .mutation(async (opts) => {
    const customer = await db
      .selectFrom("Customer")
      .where("authUserId", "=", userId)
      .executeTakeFirst();

    if (customer && customer.plan !== "FREE") {
      // Existing subscriber → billing portal
      const session = await stripe.billingPortal.sessions.create({
        customer: customer.stripeCustomerId!,
        return_url: returnUrl,
      });
      return { success: true, url: session.url };
    }

    // New subscriber → checkout
    const session = await stripe.checkout.sessions.create({
      mode: "subscription",
      payment_method_types: ["card"],
      customer_email: email,
      line_items: [{ price: planId, quantity: 1 }],
      success_url: returnUrl,
    });
    return { success: true, url: session.url };
  }),

Webhook handling for subscription events:

// packages/stripe/src/webhooks.ts
if (event.type === "checkout.session.completed") {
  // Update customer with Stripe IDs
  await db.updateTable("Customer")
    .set({
      stripeCustomerId: customerId,
      stripeSubscriptionId: subscription.id,
      stripePriceId: priceId,
    })
    .execute();
}

if (event.type === "invoice.payment_succeeded") {
  // Update plan and period end date
  const plan = getSubscriptionPlan(priceId);
  await db.updateTable("Customer")
    .set({
      plan: plan || SubscriptionPlan.FREE,
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
    })
    .execute();
}

Smart upgrade button:

// apps/nextjs/src/components/price/billing-form-button.tsx
<Button onClick={stripeSessionAction}>
  {subscriptionPlan.stripePriceId
    ? dict.manage_subscription  // Existing subscriber
    : dict.upgrade}             // Free user
</Button>

What's missing

  • No coupon/discount code support
  • No dunning for failed payments
  • No billing history page
  • No payment method management UI (only via Stripe portal)

6. Paywall and upgrade CRO

What's implemented

Clean pricing page with annual/monthly toggle:

// apps/nextjs/src/components/price/pricing-cards.tsx
<Switch
  checked={isYearly}
  onCheckedChange={toggleBilling}
/>

// Shows savings with strikethrough
{isYearly && offer?.prices?.monthly > 0 ? (
  <>
    <span className="line-through">${offer?.prices?.monthly}</span>
    <span>${offer?.prices?.yearly / 12}</span>
  </>
) : (
  `$${offer?.prices?.monthly}`
)}

Benefits and limitations clearly displayed:

<ul>
  {offer?.benefits.map((feature) => (
    <li><Icons.Check /> {feature}</li>
  ))}
  {offer?.limitations?.map((feature) => (
    <li className="text-muted-foreground">
      <Icons.Close /> {feature}
    </li>
  ))}
</ul>

What's missing

  • No "Most Popular" badge on Pro tier
  • No annual savings percentage display
  • No in-app upgrade prompts when hitting limits
  • No feature comparison table
  • No testimonials or social proof
  • No FAQ about pricing

Architecture

Saasfly uses a monorepo structure with Turborepo:

PackagePurpose
apps/nextjsMain Next.js application
packages/apitRPC routers
packages/authNextAuth configuration
packages/dbPrisma schema and client
packages/stripeStripe SDK and webhooks

This is a clean separation, but the feature gating logic needs to be added to packages/api.


What you need to add

Priority 1: Enforce feature limits (critical)

Uncomment and implement the limit check:

// packages/api/src/router/k8s.ts
createCluster: protectedProcedure.mutation(async ({ ctx }) => {
  const customer = await getCustomer(ctx.userId);
  const clusterCount = await getClusterCount(ctx.userId);

  const limits = { FREE: 1, PRO: 3, BUSINESS: 10 };
  const limit = limits[customer.plan || 'FREE'];

  if (clusterCount >= limit) {
    throw new TRPCError({
      code: 'PAYMENT_REQUIRED',
      message: `Limit of ${limit} clusters reached. Upgrade to create more.`,
    });
  }

  // ... create cluster
});

Priority 2: Add custom analytics events

posthog.capture('signup_completed', { method: 'magic_link' });
posthog.capture('cluster_created', { plan: customer.plan });
posthog.capture('checkout_started', { plan: 'pro', interval: 'yearly' });
posthog.capture('checkout_completed', { plan: 'pro', mrr: 79 });

Priority 3: Trial system

Add trial support to checkout:

const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  subscription_data: {
    trial_period_days: 14,
  },
  // ...
});

First steps to PLG

Saasfly has working checkout — fix the gating and add tracking:

Day 1: Fix the commented-out limit check

The feature gating code exists but is commented out. Uncomment and complete it:

// In cluster-create-button.tsx
async function onClick() {
  const res = await trpc.k8s.createCluster.mutate({...});

  // UNCOMMENT AND IMPLEMENT:
  if (!res.success && res.code === 'LIMIT_REACHED') {
    return toast({
      title: "Cluster limit reached",
      description: "Upgrade to Pro for more clusters.",
      variant: "destructive",
    });
  }
}

Week 1: Enforce limits in the API

Add the server-side check that's missing:

// packages/api/src/router/k8s.ts
createCluster: protectedProcedure.mutation(async ({ ctx }) => {
  const customer = await getCustomer(ctx.userId);
  const clusterCount = await countClusters(ctx.userId);

  const limits = { FREE: 1, PRO: 3, BUSINESS: 10 };
  if (clusterCount >= limits[customer.plan || 'FREE']) {
    throw new TRPCError({
      code: 'FORBIDDEN',
      message: 'Cluster limit reached'
    });
  }

  // ... create cluster
});

Week 2: Add PostHog events

PostHog is integrated but only tracks pageviews. Add custom events:

import { usePostHog } from 'posthog-js/react';

// After signup
posthog.capture('signup_completed', { method: 'magic_link' });

// After cluster creation
posthog.capture('cluster_created', { plan: customer.plan, cluster_count: count });

// When limit is hit
posthog.capture('limit_reached', { feature: 'clusters', plan: customer.plan });

// After upgrade
posthog.capture('plan_upgraded', { from: 'FREE', to: 'PRO' });

Week 3: Add upgrade prompts

Create a reusable upgrade component:

// components/upgrade-prompt.tsx
export function UpgradePrompt({ feature, currentPlan }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Upgrade to create more {feature}</CardTitle>
      </CardHeader>
      <CardContent>
        <p>You're on the {currentPlan} plan.</p>
        <Button onClick={() => router.push('/pricing')}>
          View Plans
        </Button>
      </CardContent>
    </Card>
  );
}

Week 4: Add trial

  1. Modify checkout to include trial period
  2. Show "X days left in trial" banner
  3. Send email on day 10: "Your trial is ending soon"
  4. Track trial conversion in PostHog

When to use this template

Good fit:

  • You want a clean monorepo structure
  • You need full Stripe checkout working
  • You prefer tRPC for type-safe APIs

Not ideal:

  • You need working feature gating out of the box
  • You want comprehensive analytics tracking
  • You need trial optimization

Conclusion

Saasfly scores 4/10 for PLG readiness. It has the right building blocks — PostHog analytics, full Stripe integration, clean pricing UI — but the feature gating is broken. The code that would enforce plan limits is literally commented out.

The monorepo architecture is well-organized, and the self-serve checkout flow is the most complete of the open-source options we've reviewed. But shipping this to production without fixing the feature gating would mean giving away unlimited access to paying features.

Best for: Teams who want a clean starting point and will implement the limit enforcement themselves.


Frequently asked questions

Is Saasfly really a complete alternative to paid templates?

It has good bones but needs work. The Stripe integration is solid, but feature gating isn't enforced, analytics only track pageviews, and there's no trial system. Paid templates like ShipFast include these features working out of the box.

Why are the feature limits commented out?

It appears to be an incomplete implementation. The pricing data defines limits ("Up to 1 cluster per month") but the backend code that would enforce these limits is commented out with a TODO-style pattern.

Does it work with databases other than PostgreSQL?

Prisma supports multiple databases, but the schema is designed for PostgreSQL. You'd need to adjust for MySQL or SQLite.

How does the monorepo structure compare to single-repo templates?

The Turborepo structure is cleaner for larger teams — you can work on packages/stripe without touching apps/nextjs. But it adds complexity for solo developers.


This analysis was performed using PLG Skills, an open-source framework for product-led growth audits. Skills used: signup-flow-cro, feature-gating, trial-optimization, product-analytics, self-serve-motion, paywall-upgrade-cro.

Done with this article? Explore more ways to ship real PLG.