A complete and production-ready starter kit for building a subscription-based SaaS platform.
bash1git clone https://github.com/tomangale/indiflow.git 2cd indiflow 3
bash1pnpm install 2
bash1cp .env.example .env 2
Fill in the .env file with your own values:
bash1VITE_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
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.
typescript1// 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.
typescript1// 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:
bash1pnpm dev 2
Visit http://localhost:3000 to see your application.
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:
bash1pnpm db:push 2
bash1 - 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
For local development, you can use the Stripe CLI to forward webhooks to your local server:
bash1stripe listen --forward-to localhost:3000/api/auth/stripe/webhook 2
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 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:
The formatting is handled by the PricingTable component, which:
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:
tsx1{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.
Here's how to use the limits in your application code:
typescript1// 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
This starter uses React Email with Resend for sending transactional emails.
bash1pnpm email:dev 2
This will start a local server at http://localhost:3030 where you can preview email templates.
This starter includes Sentry integration for error tracking and monitoring.
bash1# 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:
The integration is configured to:
You can access the error data in your Sentry dashboard and receive alerts when errors occur.
This starter uses Better Auth for authentication. It supports email/password authentication and OAuth providers.
To set up OAuth providers:
To generate new authentication schema:
bash1pnpm auth:generate 2
This starter can be deployed to any platform that supports Node.js. Here are some recommended options:
Start development server
bash1pnpm dev 2
Build for production
bash1pnpm build 2
Start production server
bash1pnpm start 2
Lint code
bash1pnpm lint 2
Format code
bash1pnpm format 2
Generate UI components
bash1pnpm ui 2
Manage database schema
bash1pnpm db:push 2
Generate auth schema
bash1pnpm auth:generate 2
Start email development server
bash1pnpm email:dev 2
This starter includes support for file uploads to S3-compatible storage services, useful for user avatars and other media:
For non-AWS S3 services, you may need to adjust:
The system uses pre-signed URLs for secure direct browser-to-S3 uploads, removing the load from your server.
To use the media service with Cloudflare R2, you'll need to configure your credentials in the following way:
Get Cloudflare R2 Credentials:
Environment Variables Setup:
bash1S3_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:
CORS Configuration:
json1[ 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:
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.