δΈζη | English | Changelog | Roadmap
This is a free, open-source, production-ready full-stack SaaS starter kit designed to help you launch your next project at unprecedented speed. It integrates modern web development tools and practices to provide you with a solid foundation.
It is also an agent-friendly SaaS template: humans use browser sessions, scripts and coding agents use API keys, and local tools can sign in through a browser-approved CLI device flow.
This starter kit provides a comprehensive set of powerful features to help you quickly build full-featured SaaS applications:
- Authentication (Better-Auth + Resend): Integrated with Better-Auth, providing secure magic link login and third-party OAuth functionality. Uses Resend for reliable email delivery with Mailchecker integration to avoid temporary emails.
- Machine Auth for APIs and Agents: Includes per-user API keys, CLI access tokens, refresh-token rotation, and a versioned
/api/v1/*surface for machine clients. - Modern Web Framework (Next.js 16 + TypeScript): Built on the latest Next.js 16 with App Router and Server Components. The entire project uses strict TypeScript type checking.
- Internationalization (next-intl): Server-rendered localization with explicit catalogs, locale-aware routing, localized metadata, and canonical hreflang output. See docs/i18n-next-intl.md.
- Database & ORM (Drizzle + PostgreSQL): Uses Drizzle ORM for type-safe database operations with deep PostgreSQL integration. Supports schema migrations and optimized queries.
- Payments & Subscriptions (Stripe): Integrated with Stripe as the payment provider for easy subscription and one-time payment handling.
- UI Component Library (shadcn/ui + Tailwind CSS): Built with shadcn/ui, an accessible, composable component library based on Radix UI and Tailwind CSS with built-in theme support.
- Form Handling (Zod + React Hook Form): Powerful, type-safe form validation through Zod and React Hook Form.
- File Upload (Cloudflare R2): Secure file upload system based on Cloudflare R2, supporting client-side direct upload with various file type and size restrictions.
- Agent-Ready AI Stack (Vercel AI SDK): Multi-step agent loop with a tool registry, composable skills, and a streaming chat UI. Build your own agent by registering tools and skills β auth, rate limiting, transport, and UI are already wired. See docs/ai-agent.md.
- Blog System (Content Collections): Uses Content Collections with plain Markdown files for type-safe blog content, metadata generation, and sitemap output.
- Agent-Friendly Developer Workflow: Ships with a first-party
saas-cli, browser-approved device login, API key management, and a dedicated Developer Access workspace for reviewing authorized CLI sessions. - Code Quality & Verification: Built-in ESLint, Prettier, Jest, and Playwright smoke tests to keep critical flows from regressing.
If you like this project and want to support my work, consider buying me a coffee! β
| Category | Technology |
|---|---|
| Framework | Next.js 16 |
| Language | TypeScript |
| UI | React, shadcn/ui, Tailwind v4, Lucide React (icons) |
| Auth | Better-Auth |
| Database | PostgreSQL |
| ORM | Drizzle ORM |
| Payments | Stripe |
| AI | Vercel AI SDK v7, any OpenAI-compatible LLM endpoint |
| Resend, React Email | |
| Forms | React Hook Form, Zod |
| Deployment | Zeabur or Docker |
| Package Manager | pnpm |
Ensure you have the following software installed in your development environment:
# Clone the repository
git clone https://github.com/ullrai/saas-starter.git
# Enter project directory
cd saas-starter
# Install dependencies with pnpm
pnpm installThe project is configured through environment variables. First, copy the example file:
cp .env.example .envThen edit .env and fill in the core values plus the credentials for the
features enabled in src/lib/config/site.js.
SITE_CONFIG is the client-safe source of truth for brand, contact, links,
assets, and the static emailAuth, billing, uploads, and ai feature
switches. All features default to enabled. Disable an unused feature there before
removing its environment variables. Disabled features are removed from
navigation and guarded at their pages, APIs, plugins, and server actions.
If emailAuth is disabled, configure at least one complete OAuth provider so
the web sign-in flow remains usable. Credentials remain server-only and must
never be added to SITE_CONFIG.
| Variable Name | Description | Example |
|---|---|---|
DATABASE_URL |
Required. PostgreSQL connection string. | postgresql://user:password@localhost:5432/db_name |
JOB_DATABASE_URL |
Optional pg-boss database; defaults to DATABASE_URL. |
postgresql://user:password@localhost:5432/db_name |
JOB_DB_POOL_SIZE |
Optional pg-boss pool size per process; defaults to 3. |
3 |
WORKER_GRACEFUL_TIMEOUT_MS |
Optional Worker SIGTERM drain deadline; defaults to 30 seconds. | 30000 |
RATE_LIMIT_IP_HEADER |
Optional trusted client-IP header; defaults to Zeabur. | x-forwarded-for |
NEXT_PUBLIC_APP_URL |
Required. Public URL of your deployed app. | http://localhost:3000 or https://yourdomain.com |
BING_SITE_VERIFICATION |
Optional Bing Webmaster msvalidate.01 verification token. |
Value issued for your deployed hostname |
NEXT_PUBLIC_UMAMI_SCRIPT_URL |
Optional Umami tracker URL; set all three Umami variables. | https://analytics.example.com/script.js |
NEXT_PUBLIC_UMAMI_WEBSITE_ID |
Optional deployment-specific Umami website UUID. | 00000000-0000-4000-8000-000000000000 |
NEXT_PUBLIC_UMAMI_DOMAINS |
Optional comma-separated host allowlist for this deployment. | yourdomain.com |
BETTER_AUTH_SECRET |
Required. Random session secret, at least 32 characters. | Generate with openssl rand -base64 32 |
RESEND_API_KEY |
Required when emailAuth is enabled. Resend API key. |
re_xxxxxxxxxxxxxxxx |
RESEND_EMAIL_FROM |
Required when emailAuth is enabled. Verified sender. |
noreply@your-verified-domain.com |
LLM_API_KEY |
Required when ai is enabled. Key for your LLM endpoint. |
sk-... |
LLM_BASE_URL |
Optional Responses API endpoint; defaults to OpenAI. | https://api.openai.com/v1 |
AI_DEFAULT_MODEL |
Optional chat model id; defaults to gpt-5.6-luna. |
gpt-5.6-luna |
STRIPE_SECRET_KEY |
Required for billing. Prefer a least-privilege restricted key. | rk_test_... or rk_live_... |
STRIPE_ENVIRONMENT |
Stripe mode; defaults to test_mode. |
test_mode or live_mode |
STRIPE_WEBHOOK_SECRET |
Required when billing is enabled. Endpoint signing secret. |
whsec_your_webhook_secret |
R2_ENDPOINT |
Required when uploads is enabled. R2 API endpoint. |
https://<ACCOUNT_ID>.r2.cloudflarestorage.com |
R2_ACCESS_KEY_ID |
Required when uploads is enabled. R2 access key ID. |
your_r2_access_key_id |
R2_SECRET_ACCESS_KEY |
Required when uploads is enabled. R2 secret key. |
your_r2_secret_access_key |
R2_BUCKET_NAME |
Required when uploads is enabled. R2 bucket name. |
your_r2_bucket_name |
UPLOAD_CLEANUP_SECRET |
Required when uploads is enabled. 32+ character secret. |
Generate with openssl rand -base64 32 |
UPLOAD_DAILY_QUOTA_BYTES |
Optional rolling 24-hour upload quota per user. | 1073741824 (1 GiB) |
UPLOAD_TOTAL_QUOTA_BYTES |
Optional total stored and reserved bytes per user. | 5368709120 (5 GiB) |
UPLOAD_LEGACY_COMPLETION_SINCE |
Optional ISO-8601 start of the bounded v1 compatibility window. | Set with UPLOAD_LEGACY_COMPLETION_UNTIL only |
UPLOAD_LEGACY_COMPLETION_UNTIL |
Optional ISO-8601 end of the bounded v1 compatibility window. | At most 24 hours after the matching start |
GITHUB_CLIENT_ID |
Optional. GitHub OAuth Client ID. | your_github_client_id |
GITHUB_CLIENT_SECRET |
Optional. GitHub OAuth Client Secret. | your_github_client_secret |
GOOGLE_CLIENT_ID |
Optional. Google OAuth Client ID. | your_google_client_id |
GOOGLE_CLIENT_SECRET |
Optional. Google OAuth Client Secret. | your_google_client_secret |
LINKEDIN_CLIENT_ID |
Optional. LinkedIn OAuth Client ID. | your_linkedin_client_id |
LINKEDIN_CLIENT_SECRET |
Optional. LinkedIn OAuth Client Secret. | your_linkedin_client_secret |
Tip: You can generate a secure key using the following command:
openssl rand -base64 32Optional local CLI auth: for scripts, local agents, or quick terminal access, you can export
SAAS_CLI_API_KEY=ssk_...instead of storing credentials in the CLI config.
Umami tracking is optional and disabled unless all three public Umami variables
are set. Each deployment must create its own website in Umami and use its own
website ID; never copy the maintainer deployment's ID into a fork. The tracker
honors Do Not Track, excludes URL search parameters, and only records the hosts
listed in NEXT_PUBLIC_UMAMI_DOMAINS.
The stable event vocabulary is github_source_click, clone_command_copy,
signup_click, signup_submit, signup_link_sent, login_submit,
signup_success, login_link_sent, pricing_view, payment_start, and
payment_success.
Tracking is best-effort and never controls authentication or billing behavior.
Umami's accounting remains separate from provider webhooks and database records.
This configuration is cookieless, but operators remain responsible for their privacy notice and any consent flow required by the data they add or the jurisdictions they serve. Verification and SEO measurement procedures are in SEO growth operations.
Implementation guides:
- Next.js 16 SaaS starter architecture
- Stripe billing with Next.js
- API keys vs OAuth vs device flow for SaaS agents
Test and live Stripe catalog IDs are intentionally separate in
src/lib/billing/stripe/prices.ts. Set STRIPE_ENVIRONMENT and its matching
STRIPE_SECRET_KEY, then run pnpm stripe:sync-products. Each tier owns one
stable Stripe Product; a price change creates a new Price under that Product,
deactivates the old Price for new checkouts, and leaves existing subscriptions
on their original amount. Webhooks resolve the tier from the Product ID, so no
finite or unbounded retired-Price list is needed. Review and commit the
selected-environment configuration change before deploying. Checkout fails
closed when the active environment has no configured Price ID.
Webhook retry, idempotency, and manual replay behavior are documented in Billing webhook operations.
This project uses a single Drizzle config file, src/database/config.ts, and a single committed migration history in src/database/migrations/. The target database is selected only by DATABASE_URL.
For fast local iteration against your own database:
pnpm db:pushIf the schema change should be reviewed, committed, or shared with other environments, create and apply a real migration instead:
pnpm db:generate
pnpm db:migrateShared environments should use committed SQL migrations only:
# 1. Generate and commit the migration from your schema change
pnpm db:generate
# 2. Run the committed migration once against the target DATABASE_URL
pnpm db:migrate
# 3. Deploy the application after the migration succeedsRecommended release practice
- Never use
pnpm db:pushin staging or production.- Keep one migration history for all environments. Do not split migrations into dev/prod trees.
- Run
pnpm db:migrateas a dedicated one-shot release step in CI/CD or your deploy platform.- Do not run migrations on every application process startup.
- Make schema changes backward-compatible when possible, so app rollout and migration timing stay safe.
The project uses Content Collections plus plain Markdown files for blog content. Posts live in locale-scoped paths such as content/blog/en/*.md and content/blog/zh-Hans/*.md, authors live in content/authors/*.json, and build-time generation produces typed collections for the blog pages and sitemap.
- Authoring workflow: Add or edit posts directly in the repository with frontmatter and Markdown content.
- Generated content data: Run
pnpm content:buildto refresh the generated collections manually. The Next.js plugin handles development and production builds; test and type-check scripts invoke the generator explicitly. - Production behavior: There is no CMS admin route or runtime content API. All blog content is built from the repository content files.
This starter distinguishes clearly between human auth and machine auth:
- Browser users: Better Auth session cookies for the web app
- Server-to-server and agent access: user-managed API keys
- Local developer tools: browser-approved device login via
saas-cli
What ships today:
- versioned machine endpoints under
/api/v1/* - API key creation and revocation in Dashboard Settings
- CLI session review and revocation in Dashboard Settings
- a terminal workflow for signing in from local tools without reusing browser session tokens
Quick examples:
# Sign in a local CLI through the browser
pnpm saas-cli -- auth login --base-url http://localhost:3000
# Check current CLI auth state
pnpm saas-cli -- auth status --base-url http://localhost:3000
# Use an API key for scripts or coding agents
SAAS_CLI_API_KEY=ssk_your_key_here pnpm saas-cli -- auth status --base-url http://localhost:3000The web app exposes management surfaces at /dashboard/developer for both API keys and authorized CLI sessions.
pnpm devNow your application should be running at http://localhost:3000!
For security reasons, the first registered user is not promoted automatically. Use the admin script after the user has signed up normally:
pnpm set:admin --email=your-email@example.comThe command loads .env if it exists and otherwise uses the current process environment, so the same command works locally and on a server.
After successful execution, the user receives super_admin privileges and can access /dashboard/admin.
Security tips
- Grant this role only to trusted users.
- Run the command in a secure environment with the correct
DATABASE_URL.
| Script | Description |
|---|---|
pnpm dev |
Start development server. |
pnpm build |
Build application for production. |
pnpm start |
Start production server. |
pnpm saas-cli |
Run the first-party CLI for device login and API verification. |
pnpm lint |
Check code for linting errors. |
pnpm dead-code:check |
Detect unused files, exports, and dependencies. |
pnpm type-check |
Run TypeScript type checking. |
pnpm test |
Run the Jest test suite. |
pnpm test:coverage |
Run Jest and generate a coverage report. |
pnpm test:e2e |
Build and run Playwright E2E smoke tests. |
pnpm prettier:format |
Format all code using Prettier. |
pnpm set:admin |
Promote specified email user to super admin. |
This repository includes a Playwright smoke test suite in e2e/ for the most important browser-level flows:
- unauthenticated dashboard redirect
- authenticated dashboard access
- admin permission gating
- locale canonicalization for marketing routes
- API key creation and machine-auth verification
- browser-approved device auth for CLI sign-in
- Use
ShellContainerfor the marketing header, footer, and other truly wide layouts. - Use
SectionContainerfor standard marketing sections and non-dashboard page bodies. - Use
ReadingContainerfor blog articles, legal pages, and other long-form reading surfaces. - Use
CompactContainerfor auth flows. - Use
FocusContainerfor payment status and other centered cards that need more space. - Keep full-bleed backgrounds separate from content width. Backgrounds can span the viewport while content stays inside one semantic container.
Run the suite with:
createdb saas_e2e
# Add E2E_DATABASE_URL=postgresql://.../saas_e2e to .env
pnpm test:e2eE2E_DATABASE_URL is mandatory and must point to a dedicated local PostgreSQL database whose name contains a standalone e2e or test segment. Outside CI, the runner rejects the regular DATABASE_URL to prevent test users, API keys, CLI tokens, device codes, and rate-limit state from reaching a development or shared database. It applies migrations, builds the app, starts the production server, and cleans E2E fixtures before and after the suite.
The test-only session route is enabled only while Playwright is running against the declared E2E database. It also requires an explicit E2E_TEST_SECRET of at least 32 characters and is disabled for non-local production deployments. Playwright generates a per-run secret when CI does not provide one.
| Script | Description |
|---|---|
pnpm analyze |
Build application and generate bundle analysis report. |
pnpm analyze:dev |
Enable bundle analysis in development mode. |
| Script | Description |
|---|---|
pnpm db:generate |
Generate SQL migration files from schema changes. |
pnpm db:migrate |
Apply committed migrations to the database selected by DATABASE_URL. |
pnpm db:push |
Local development only. Sync schema directly without creating migration |
This project integrates a secure file upload system based on Cloudflare R2. Direct and server-side uploads share the same short-lived upload intent, per-user byte quotas, one-time completion, and orphan cleanup flow.
- Create R2 Bucket: Log into Cloudflare Dashboard, navigate to R2 and create a new bucket.
- Get API Token: In the R2 overview page, click "Manage R2 API Tokens", create a token with "Object Read & Write" permissions. Note down the
Access Key IDandSecret Access Key. - Set Environment Variables: Fill your R2 credentials and information into the
.envfile. - Configure CORS Policy: To allow browsers to upload files directly, you need to configure CORS policy in your R2 bucket's "Settings". Add the following configuration, replacing the URLs in
AllowedOriginswith your own:
[
{
"AllowedOrigins": ["http://localhost:3000", "https://yourdomain.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type", "If-None-Match"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]Direct uploads use protocol version 2. The presign response declares the
required Content-Type and If-None-Match: * headers, so an issued object key
can only be written once. The signed URL lasts 15 minutes and its database
reservation lasts one hour. Cancelling releases quota immediately without
shortening that original expiry. After expiry, cleanup deletes the object but
retains a tombstone for 24 hours and deletes the object again before removing
the intent. This second check catches a late PUT that began while the signed URL
was still valid. Clients built against the older unsigned-header protocol must
be refreshed when this version is deployed.
For a rolling v1-to-v2 deployment, set UPLOAD_LEGACY_COMPLETION_SINCE to the
rollout start and UPLOAD_LEGACY_COMPLETION_UNTIL to an absolute timestamp no
more than 24 hours later. The compatibility path only accepts the authenticated
user's timestamp-and-UUID v1 keys issued up to 15 minutes before that start,
verifies the declared URL and actual R2 metadata, applies upload quotas, and is
idempotent. Remove both variables after the cutoff. Then perform a one-time,
dry-run inventory comparison between R2's uploads/ prefix and the upload
records' "fileKey" values; review the difference before deleting untracked
legacy objects. New clients always require a database-backed v2 intent.
Call the cleanup endpoint once per day from your deployment platform. It claims expired upload intents and deletes abandoned R2 objects. At this cadence, expired objects may remain until a later daily run. A tombstone that becomes eligible just after the fixed run can wait through one additional daily cycle; quota accounting stops counting intents as soon as they expire.
curl -fsS -X POST \
-H "Authorization: Bearer $UPLOAD_CLEANUP_SECRET" \
"https://yourdomain.com/api/internal/uploads/cleanup"The endpoint processes up to five batches of 100 intents per run and recovers stale cleanup claims automatically. If a run reports five full batches, invoke it again or schedule it more frequently until the queue is drained. R2 lifecycle rules may additionally abort incomplete multipart uploads after one day, but they do not replace this database-aware cleanup.
We provide a powerful FileUploader component that supports drag-and-drop, progress display, image compression, and error handling.
import { FileUploader } from "@/components/ui/file-uploader";
function MyComponent() {
const handleUploadComplete = (files) => {
console.log("Upload complete:", files);
// Handle uploaded file information here
};
return (
<FileUploader
acceptedFileTypes={["image/png", "image/jpeg", "application/pdf"]}
maxFileSize={5 * 1024 * 1024} // 5MB
maxFiles={3}
onUploadComplete={handleUploadComplete}
/>
);
}Note: This project uses a
srcdirectory structure. All components and library files are located in thesrc/directory and can be accessed through the@/path mapping which resolves tosrc/.
The component includes built-in client-side image compression functionality that can reduce image file size before upload, saving bandwidth and storage space.
<FileUploader
acceptedFileTypes={["image/png", "image/jpeg", "image/webp"]}
enableImageCompression={true}
imageCompressionQuality={0.7} // Compression quality (0.1-1.0)
imageCompressionMaxWidth={1200} // Maximum width after compression
/>This project integrates @next/bundle-analyzer to help you analyze and optimize your application's bundle size.
# Analyze production build
pnpm analyze
# Analyze in development mode
pnpm analyze:devAfter execution, bundle size analysis reports for both client and server will automatically open in your browser.
- Dynamic Imports: Use
next/dynamicfor code splitting of large components or libraries that aren't needed on first screen. - Dependency Optimization:
- Tree Shaking: Ensure you only import what you need from libraries, e.g.,
import { debounce } from 'lodash-es';instead ofimport _ from 'lodash';. - Lightweight Alternatives: Consider using lighter libraries, e.g., replace
moment.jswithdate-fns.
- Tree Shaking: Ensure you only import what you need from libraries, e.g.,
- Image Optimization: Prioritize using Next.js
<Image>component and enable WebP format.
The production reference deployment uses Zeabur. The repository also includes a standalone multi-stage Docker build.
Save 10% on a Zeabur server: Purchase a server at Zeabur and enter referral code
visoarat checkout.
Configure the production Zeabur service to deploy the prod branch, not
the default development branch (main in this repository). Pushing a
release/vX.Y.Z tag matching the version in package.json runs
promote-release-to-prod.yml,
which verifies that the tagged commit belongs to the repository's default
branch (main at present) before moving prod to that commit. Zeabur deploys
only after the promotion succeeds. Fork maintainers can reuse the same setup;
see the Zeabur deployment guide.
-
Merge the reviewed commit into the default branch and wait for the Quality workflow to pass.
-
Configure every required variable from
.env.example. SetNEXT_PUBLIC_APP_URLto the final HTTPS origin before building because canonical URLs and client configuration are compiled from it. Keep the user upload bucket private; see private-file cutover. -
Set
PRODUCTION_DATABASE_URLin the GitHubproductionenvironment, plusPRODUCTION_JOB_DATABASE_URLfor a separate queue database. The release workflow checks the exact SHA's Quality result and runs migrations before promotion. -
Update the version in
package.json, then tag that commit with an annotatedrelease/vX.Y.Ztag using the same version and push it:git tag -a release/v1.2.3 -m "Release v1.2.3" git push origin release/v1.2.3 -
Wait for the promotion workflow and the subsequent Zeabur deployment to succeed. Use
/api/healthfor liveness and/api/readyfor database-backed readiness. -
Schedule an authenticated
POST /api/internal/uploads/cleanuponce per day. -
Verify the public origin, both locale URL variants, authentication redirects, Dashboard access,
robots.txt,sitemap.xml, and application logs.
Docker Compose follows the same order with a one-shot migrate service. See
docker/README.md for local and self-hosted instructions.
The optional GitHub maintenance schedule is best-effort and runs only from the
default branch. Enable scheduled Actions explicitly in a fork, monitor its
latest successful run, and use a platform scheduler when timing is an SLA.
This project is licensed under the MIT license.
