A complete and production-ready starter kit for building a subscription-based SaaS platform.
- ✅ 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 Management – Tanstack Query and tRPC for type-safe API calls
- 🔍 Form Handling – Tanstack 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
- React – UI library
- TypeScript – Type safety
- Tailwind CSS – Styling
- Drizzle ORM – Database ORM
- PostgreSQL – Database
- Tanstack – Query, Router, Form
- tRPC – Type-safe API
- Stripe – Payment processing
- Better Auth – Authentication
- @better-auth/stripe – Stripe integration
- React Email – Email templates
- Resend – Email delivery
- Vinxi – Development server
- Sentry – Error tracking and monitoring
- Bun (recommended package manager)
- PostgreSQL database (local or cloud)
- Clone the repository:
git clone https://github.com/tomangale/indiflow.git
cd indiflow- Install dependencies:
bun install- Set up environment variables:
cp .env.example .envFill in the .env file with your own values:
VITE_BASE_URL=http://localhost:3000
DATABASE_URL=postgresql://username:password@localhost:5432/database
# You can also use Docker Compose to set up a local PostgreSQL database:
# docker-compose up -d
# Better Auth setup
BETTER_AUTH_SECRET=generate_a_random_string_here
# OAuth2 Providers (optional)
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Stripe configuration (for subscription features)
STRIPE_WEBHOOK_SECRET=
STRIPE_SECRET_KEY=
VITE_STRIPE_PUBLISHABLE_KEY=
# Email configuration
RESEND_API_KEY=
FROM_EMAIL=Your SaaS <onboarding@yourdomain.com>
# Sentry error tracking (optional)
VITE_SENTRY_DSN=
SENTRY_AUTH_TOKEN=
SENTRY_ORG=
SENTRY_PROJECT=
# S3 Compatible Storage for Files/Media
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=us-east-1
S3_ENDPOINT=https://s3.amazonaws.com
S3_BUCKET_NAME=your-bucket-name
S3_PUBLIC_URL=https://your-bucket-name.s3.amazonaws.com
# Cloudflare R2
# S3_ACCESS_KEY=your_cloudflare_access_key_id
# S3_SECRET_KEY=your_cloudflare_secret_access_key
# S3_REGION=auto
# S3_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
# S3_BUCKET_NAME=your_r2_bucket_name
# S3_PUBLIC_URL=https://<CUSTOM_DOMAIN>.comThis 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.
// Import in client-side code
import { clientEnv } from "../lib/client/env.client";
// Access variables
const stripeKey = clientEnv.VITE_STRIPE_PUBLISHABLE_KEY;
const baseUrl = clientEnv.VITE_BASE_URL;Server-Side Environment Variables
Server-side variables are for sensitive data that should never be exposed to the client.
// Import in server-side code only
import { serverEnv } from "../lib/server/env.server";
// Access variables
const stripeSecret = serverEnv.STRIPE_SECRET_KEY;
const databaseUrl = serverEnv.DATABASE_URL;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
- Start the development server:
bun devVisit 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:
bun db:push- Register for a Stripe account
- On the dashboard, find and copy the
Publishable keyand add it toVITE_STRIPE_PUBLISHABLE_KEYin your.envfile - Find the
Secret keyand add it toSTRIPE_SECRET_KEYin your.envfile - In test mode, go to Product catalog and create a new product
- Choose a name for the product and make sure the price is set to recurring
- Create a price for the monthly subscription and a price for the yearly subscription
- 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
- 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 planlimit:storage=5120- Sets a storage limit of 5GB (in MB) for this planlimit:projects=10- Sets a limit of 10 projects for this plan
- Activate the billing portal here: https://dashboard.stripe.com/test/settings/billing/portal
- Go to Developers > Webhooks and add a new endpoint
- Set the endpoint to
${YOUR_DOMAIN}/api/auth/stripe/webhook - Choose the following events:
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deleted
- price.updated
- price.created
- price.deleted
- product.updated
- product.created
- product.deleted- Get the signing secret from the webhook settings and add it to
STRIPE_WEBHOOK_SECRETin your.envfile
For local development, you can use the Stripe CLI to forward webhooks to your local server:
stripe listen --forward-to localhost:3000/api/auth/stripe/webhookThe 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:
- A limit set as
limit:users=100will appear as "100 x users" - A limit set as
limit:storage=5120will appear as "5120 x storage" - A limit set as
limit:projects=10will appear as "10 x projects"
The formatting is handled by the PricingTable component, which:
- Fetches all product data including limits from Stripe
- Renders marketing features with checkmarks (✓)
- 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:
{Object.entries(product.limits).map(([key, value]) => (
<li className="flex space-x-2" key={key}>
<CoinsIcon className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-500" />
<span className="text-muted-foreground">
{value} x {key}
</span>
</li>
))}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:
// Server-side code to check subscription limits
import { useTRPC } from "@//trpc/react";
import { TRPCError } from "@trpc/server";
// In your tRPC procedure
const { ctx } = opts;
const { user, subscription } = ctx;
// Example: Check if user is within their plan's user limit
if (!subscription) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You need a subscription to perform this action",
});
}
// Get the plan limit
const userLimit = subscription.limits.users || 0;
// Query current usage
const currentUsersCount = await db.query.users.count({
where: eq(users.organizationId, user.organizationId),
});
// Enforce the limit
if (currentUsersCount >= userLimit) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You've reached your plan's user limit. Please upgrade to add more users.",
});
}This starter uses React Email with Resend for sending transactional emails.
- Sign up for Resend
- Create an API key and add it to RESEND_API_KEY in your .env file
- Update the FROM_EMAIL in your .env file
- To test email templates locally:
bun email:devThis 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.
- Sign up for Sentry
- Create a new project
- Add the following environment variables to your
.envfile:
# Sentry configuration
VITE_SENTRY_DSN=your_sentry_dsn_url
SENTRY_AUTH_TOKEN=your_sentry_auth_token
SENTRY_ORG=your_sentry_organization
SENTRY_PROJECT=your_sentry_project_nameWhere:
VITE_SENTRY_DSNis the client-side DSN URL from your Sentry project settingsSENTRY_AUTH_TOKENis used for source map uploads (create in Account Settings > API)SENTRY_ORGis your Sentry organization slugSENTRY_PROJECTis 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.
This starter uses Better Auth for authentication. It supports email/password authentication and OAuth providers.
To set up OAuth providers:
- Create OAuth apps with the providers you want to support (e.g., GitHub, Google)
- Add the client IDs and secrets to your .env file
- 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:
bun auth:generateThis starter can be deployed to any platform that supports Node.js. Here are some recommended options:
- Create a Railway account
- Create a new project
- Connect your GitHub repository
- Add environment variables
- Deploy
Guide: https://tanstack.com/start/latest/docs/framework/react/hosting#vercel
- Create a Vercel account
- Connect your GitHub repository
- Add environment variables
- Uncomment the
preset: vercelline in app.config.ts - Deploy
Start development server
bun devBuild for production
bun buildStart production server
bun startLint code
bun lintFormat code
bun formatGenerate UI components
bun uiManage database schema
bun db:pushGenerate auth schema
bun auth:generateStart email development server
bun email:dev/src- Source code/lib- Core functionality/client- Client-side utilitiesenv.client.ts- Type-safe client environment variables
/components- React components/middleware- Application middleware/server- Server-side codeenv.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 endpointsconsole/- User dashboardprofile/- User profile managementsubscription/- Subscription management
/trpc- tRPC configuration
/emails- Email templates/public- Static assets
This starter includes support for file uploads to S3-compatible storage services, useful for user avatars and other media:
- Set up an S3 bucket (AWS S3, DigitalOcean Spaces, MinIO, etc.)
- Configure CORS on your bucket to allow uploads from your domain
- Add the following environment variables to your
.envfile:
- 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_ENDPOINTto your provider's endpoint (e.g.,https://nyc3.digitaloceanspaces.comfor DigitalOcean)S3_PUBLIC_URLto 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.
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:
S3_ACCESS_KEY=your_cloudflare_access_key_id
S3_SECRET_KEY=your_cloudflare_secret_access_key
S3_REGION=auto
S3_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
S3_BUCKET_NAME=your_r2_bucket_name
S3_PUBLIC_URL=https://<CUSTOM_DOMAIN>.comWhere:
- <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:
[
{
"AllowedOrigins": ["https://yourdomain.com", "http://localhost:3000"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": [],
"MaxAgeSeconds": 3000
}
]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 S3_PUBLIC_URL 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.