This is a demo site. Purchase Indiflow at indiflow.app

SaaS Starter Kit

A complete and production-ready starter kit for building a subscription-based SaaS platform.

Features

  • Authentication – Email/password and OAuth providers (GitHub, Google) via Better Auth
  • 💰 Subscription System – Complete Stripe integration with webhooks using @better-auth/stripe
  • 📧 Email System – Transactional emails with React Email and Resend
  • 🖼️ File Storage – Support for S3-compatible storage with pre-signed URLs
  • 🐞 Error Tracking – Integrated error reporting with Sentry
  • 🛠️ Full-Stack TypeScript – End-to-end type safety
  • 🎨 Modern UI Components – Built with Tailwind CSS and Radix UI
  • 🔄 State ManagementTanstack Query and tRPC for type-safe API calls
  • 🔍 Form HandlingTanstack Form with Zod validation
  • 🌓 Dark Mode – Built-in dark/light theme support
  • 📱 Responsive Design – Mobile-first approach
  • 🚀 Easy Deployment – Deploy to Railway, Vercel, or any other platform

Tech Stack

Getting Started

Prerequisites

  • Node.js (v22 or higher)
  • pnpm (recommended package manager)
  • PostgreSQL database (local or cloud)

Installation

  1. Clone the repository:
bash
1git clone https://github.com/tomangale/indiflow.git 2cd indiflow 3
  1. Install dependencies:
bash
1pnpm install 2
  1. Set up environment variables:
bash
1cp .env.example .env 2

Fill in the .env file with your own values:

bash
1VITE_BASE_URL=http://localhost:3000 2 3DATABASE_URL=postgresql://username:password@localhost:5432/database 4# You can also use Docker Compose to set up a local PostgreSQL database: 5# docker-compose up -d 6 7# Better Auth setup 8BETTER_AUTH_SECRET=generate_a_random_string_here 9 10# OAuth2 Providers (optional) 11GITHUB_CLIENT_ID= 12GITHUB_CLIENT_SECRET= 13GOOGLE_CLIENT_ID= 14GOOGLE_CLIENT_SECRET= 15 16# Stripe configuration (for subscription features) 17STRIPE_WEBHOOK_SECRET= 18STRIPE_SECRET_KEY= 19VITE_STRIPE_PUBLISHABLE_KEY= 20 21# Email configuration 22RESEND_API_KEY= 23FROM_EMAIL=Your SaaS <onboarding@yourdomain.com> 24 25# Sentry error tracking (optional) 26VITE_SENTRY_DSN= 27SENTRY_AUTH_TOKEN= 28SENTRY_ORG= 29SENTRY_PROJECT= 30 31# S3 Compatible Storage for Files/Media 32S3_ACCESS_KEY= 33S3_SECRET_KEY= 34S3_REGION=us-east-1 35S3_ENDPOINT=https://s3.amazonaws.com 36S3_BUCKET_NAME=your-bucket-name 37S3_PUBLIC_URL=https://your-bucket-name.s3.amazonaws.com 38 39# Cloudflare R2 40# S3_ACCESS_KEY=your_cloudflare_access_key_id 41# S3_SECRET_KEY=your_cloudflare_secret_access_key 42# S3_REGION=auto 43# S3_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com 44# S3_BUCKET_NAME=your_r2_bucket_name 45# S3_PUBLIC_URL=https://<CUSTOM_DOMAIN>.com 46

Environment variables usage

This starter uses a type-safe approach to environment variables with Zod validation:

Client-Side Environment Variables

All client-side environment variables must start with VITE_ to be accessible in the browser.

typescript
1// Import in client-side code 2import { clientEnv } from "../lib/client/env.client"; 3 4// Access variables 5const stripeKey = clientEnv.VITE_STRIPE_PUBLISHABLE_KEY; 6const baseUrl = clientEnv.VITE_BASE_URL; 7

Server-Side Environment Variables

Server-side variables are for sensitive data that should never be exposed to the client.

typescript
1// Import in server-side code only 2import { serverEnv } from "../lib/server/env.server"; 3 4// Access variables 5const stripeSecret = serverEnv.STRIPE_SECRET_KEY; 6const databaseUrl = serverEnv.DATABASE_URL; 7

The environment system ensures:

  • Type safety with Zod validation
  • Proper error messages for missing variables
  • Security through separation of client/server environments
  • Protection against accidentally exposing secrets to the client
  1. Start the development server:
bash
1pnpm dev 2

Visit http://localhost:3000 to see your application.

Database Setup

This starter uses Drizzle ORM with PostgreSQL. You can set up a local PostgreSQL instance or use a cloud provider like Neon or Supabase.

To push schema changes to your database:

bash
1pnpm db:push 2

Setting Up Stripe

Creating Products and Prices

  1. Register for a Stripe account
  2. On the dashboard, find and copy the Publishable key and add it to VITE_STRIPE_PUBLISHABLE_KEY in your .env file
  3. Find the Secret key and add it to STRIPE_SECRET_KEY in your .env file
  4. In test mode, go to Product catalog and create a new product
  5. Choose a name for the product and make sure the price is set to recurring
  6. Create a price for the monthly subscription and a price for the yearly subscription
  7. Fill in the product description and marketing features in Stripe:
    • The Description field is displayed as a subtitle under the plan name on the pricing page
    • Marketing Features are displayed as bullet points with checkmarks on the pricing table
    • Both will automatically appear on your pricing page with no additional code required
  8. Add limits to your subscription plans by setting metadata fields with a limit: prefix. For example:
    • limit:users=100 - Sets a limit of 100 users for this plan
    • limit:storage=5120 - Sets a storage limit of 5GB (in MB) for this plan
    • limit:projects=10 - Sets a limit of 10 projects for this plan
  9. Activate the billing portal here: https://dashboard.stripe.com/test/settings/billing/portal

Setting Up Webhooks

  1. Go to Developers > Webhooks and add a new endpoint
  2. Set the endpoint to ${YOUR_DOMAIN}/api/auth/stripe/webhook
  3. Choose the following events:
bash
1 - customer.subscription.created 2 - customer.subscription.updated 3 - customer.subscription.deleted 4 - price.updated 5 - price.created 6 - price.deleted 7 - product.updated 8 - product.created 9 - product.deleted 10
  1. Get the signing secret from the webhook settings and add it to STRIPE_WEBHOOK_SECRET in your .env file

For local development, you can use the Stripe CLI to forward webhooks to your local server:

bash
1stripe listen --forward-to localhost:3000/api/auth/stripe/webhook 2

Using Subscription Limits in Your Code

The system automatically reads any metadata fields with the limit: prefix from your Stripe products and makes them available in your application. These limits are also automatically displayed on your pricing table.

Limits on the Pricing Table

Limits you define in Stripe will automatically appear on the pricing page for each plan, displayed with a coin icon (🪙) to differentiate them from regular features. For example:

  • A limit set as limit:users=100 will appear as "100 x users"
  • A limit set as limit:storage=5120 will appear as "5120 x storage"
  • A limit set as limit:projects=10 will appear as "10 x projects"

The formatting is handled by the PricingTable component, which:

  1. Fetches all product data including limits from Stripe
  2. Renders marketing features with checkmarks (✓)
  3. Renders limits with coin icons (🪙)

No additional code is required to display these limits—they're automatically fetched from Stripe and displayed as part of the pricing table.

If you need to customize how limits are displayed, you can modify the PricingTable component in src/lib/components/PricingTable.tsx. Look for the following code block:

tsx
1{Object.entries(product.limits).map(([key, value]) => ( 2 <li className="flex space-x-2" key={key}> 3 <CoinsIcon className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-500" /> 4 <span className="text-muted-foreground"> 5 {value} x {key} 6 </span> 7 </li> 8))} 9

You can customize this to format specific limits differently, for example to show storage in GB instead of raw MB values.

Using Limits Programmatically

Here's how to use the limits in your application code:

typescript
1// Server-side code to check subscription limits 2import { useTRPC } from "@//trpc/react"; 3import { TRPCError } from "@trpc/server"; 4 5// In your tRPC procedure 6const { ctx } = opts; 7const { user, subscription } = ctx; 8 9// Example: Check if user is within their plan's user limit 10if (!subscription) { 11 throw new TRPCError({ 12 code: "FORBIDDEN", 13 message: "You need a subscription to perform this action", 14 }); 15} 16 17// Get the plan limit 18const userLimit = subscription.limits.users || 0; 19 20// Query current usage 21const currentUsersCount = await db.query.users.count({ 22 where: eq(users.organizationId, user.organizationId), 23}); 24 25// Enforce the limit 26if (currentUsersCount >= userLimit) { 27 throw new TRPCError({ 28 code: "FORBIDDEN", 29 message: "You've reached your plan's user limit. Please upgrade to add more users.", 30 }); 31} 32

Email Setup

This starter uses React Email with Resend for sending transactional emails.

  1. Sign up for Resend
  2. Create an API key and add it to RESENDAPIKEY in your .env file
  3. Update the FROM_EMAIL in your .env file
  4. To test email templates locally:
bash
1pnpm email:dev 2

This will start a local server at http://localhost:3030 where you can preview email templates.

Sentry Error Tracking

This starter includes Sentry integration for error tracking and monitoring.

  1. Sign up for Sentry
  2. Create a new project
  3. Add the following environment variables to your .env file:
bash
1# Sentry configuration 2VITE_SENTRY_DSN=your_sentry_dsn_url 3SENTRY_AUTH_TOKEN=your_sentry_auth_token 4SENTRY_ORG=your_sentry_organization 5SENTRY_PROJECT=your_sentry_project_name 6

Where:

  • VITE_SENTRY_DSN is the client-side DSN URL from your Sentry project settings
  • SENTRY_AUTH_TOKEN is used for source map uploads (create in Account Settings > API)
  • SENTRY_ORG is your Sentry organization slug
  • SENTRY_PROJECT is your Sentry project slug

The integration is configured to:

  • Track errors on both client and server sides
  • Upload source maps automatically in production builds
  • Add Sentry middleware for handling server-side errors

You can access the error data in your Sentry dashboard and receive alerts when errors occur.

Authentication

This starter uses Better Auth for authentication. It supports email/password authentication and OAuth providers.

To set up OAuth providers:

  1. Create OAuth apps with the providers you want to support (e.g., GitHub, Google)
  2. Add the client IDs and secrets to your .env file
  3. Set the callback URLs to http://localhost:3000/api/auth/callback/ (e.g., http://localhost:3000/api/auth/callback/github)

To generate new authentication schema:

bash
1pnpm auth:generate 2

Deployment

This starter can be deployed to any platform that supports Node.js. Here are some recommended options:

Railway (Recommended)

  1. Create a Railway account
  2. Create a new project
  3. Connect your GitHub repository
  4. Add environment variables
  5. Deploy

Other Deployment Options

Development Commands

Start development server

bash
1pnpm dev 2

Build for production

bash
1pnpm build 2

Start production server

bash
1pnpm start 2

Lint code

bash
1pnpm lint 2

Format code

bash
1pnpm format 2

Generate UI components

bash
1pnpm ui 2

Manage database schema

bash
1pnpm db:push 2

Generate auth schema

bash
1pnpm auth:generate 2

Start email development server

bash
1pnpm email:dev 2

Project Structure

  • /src - Source code
    • /lib - Core functionality
      • /client - Client-side utilities
        • env.client.ts - Type-safe client environment variables
      • /components - React components
      • /middleware - Application middleware
      • /server - Server-side code
        • env.server.ts - Type-safe server environment variables
        • /modules - Feature modules (user, stripe, email)
        • /schema - Database schema
        • /utils - Server utilities (logger, caching, etc.)
    • /routes - Application routes
      • __root.tsx - Root layout
      • _auth/ - Authentication routes (sign in, sign up)
      • api/ - API endpoints
      • console/ - User dashboard
      • profile/ - User profile management
      • subscription/ - Subscription management
    • /trpc - tRPC configuration
  • /emails - Email templates
  • /public - Static assets

File uploads

This starter includes support for file uploads to S3-compatible storage services, useful for user avatars and other media:

  1. Set up an S3 bucket (AWS S3, DigitalOcean Spaces, MinIO, etc.)
  2. Configure CORS on your bucket to allow uploads from your domain
  3. Add the following environment variables to your .env file: - S3_ACCESS_KEY=your_access_key - S3_SECRET_KEY=your_secret_key - S3_REGION=your_region (e.g., us-east-1) - S3_ENDPOINT=your_endpoint (e.g., https://s3.amazonaws.com) - S3_BUCKET_NAME=your_bucket_name - S3_PUBLIC_URL=your_bucket_public_url (e.g., https://your-bucket.s3.amazonaws.com)

For non-AWS S3 services, you may need to adjust:

  • S3_ENDPOINT to your provider's endpoint (e.g., https://nyc3.digitaloceanspaces.com for DigitalOcean)
  • S3_PUBLIC_URL to your provider's public URL format

The system uses pre-signed URLs for secure direct browser-to-S3 uploads, removing the load from your server.

Cloudflare R2

To use the media service with Cloudflare R2, you'll need to configure your credentials in the following way:

Get Cloudflare R2 Credentials:

  • Log into your Cloudflare dashboard
  • Navigate to R2 section
  • Create an R2 bucket if you haven't already
  • Generate API tokens with appropriate permissions (R2 Admin)
  • You'll get an Access Key ID and Secret Access Key

Environment Variables Setup:

  • Add these to your .env file:
bash
1S3_ACCESS_KEY=your_cloudflare_access_key_id 2S3_SECRET_KEY=your_cloudflare_secret_access_key 3S3_REGION=auto 4S3_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com 5S3_BUCKET_NAME=your_r2_bucket_name 6S3_PUBLIC_URL=https://<CUSTOM_DOMAIN>.com 7

Where:

  • <ACCOUNT_ID> is your Cloudflare account ID (found in dashboard URL)
  • <CUSTOM_DOMAIN> is your public domain for the bucket (optional)

CORS Configuration:

  • Configure CORS rules on your R2 bucket to allow uploads from your domain:
json
1[ 2 { 3 "AllowedOrigins": [ 4 "https://yourdomain.com", 5 "http://localhost:3000" 6 ], 7 "AllowedMethods": [ 8 "GET", 9 "PUT", 10 "POST" 11 ], 12 "AllowedHeaders": [ 13 "*" 14 ], 15 "ExposeHeaders": [], 16 "MaxAgeSeconds": 3000 17 } 18]

Public Access (Optional): If you want files to be publicly accessible, you'll need to:

  • Set up a Custom Domain for your R2 bucket in Cloudflare
  • Update S3PUBLICURL to point to this custom domain
  • Configure public access policies for your bucket

The media service will work with R2 without code changes because Cloudflare R2 is designed to be S3-compatible with the AWS SDK. The key difference is just the endpoint URL format.