Getting Started

Installation

Prerequisites

Before you begin, make sure you have:

  • Node.js >= 20 (Vercel runs 20.x in production)
  • A Clerk account — free tier, API keys for dev and prod
  • A Neon Postgres database — free tier, pooled connection string
  • A Stripe account — test mode keys for payments
  • A Resend API key — for transactional email

Clone the starter

One command pulls the entire boilerplate with a clean git history:

terminal
$ npx create-next-app@latest my-app \
  --example https://github.com/sirconscious/starter-kit
terminal
$ cd my-app
$ npm install

Set environment variables

Copy .env.example to .env.local and fill in your keys:

terminal
$ cp .env.example .env.local

See the Environment Variables page for where to find each value.

Run the dev server

terminal
$ npm run dev

Open http://localhost:3000. You should see the app with sign-in, sign-up, and user profile pages already wired.

What's included

Auth — Clerk pre-wired
Database — Prisma + Neon
Payments — Stripe checkout + webhooks
UI — shadcn/ui + Tailwind, dark mode
Email — Resend + React templates
Types — Zod shared client/server
Tests — Playwright e2e + Vitest
Deploy — Vercel-ready, migrations on build

Next: Set up your environment variables →

Getting Started

Environment Variables

The starter requires four environment variables. They are all documented in .env.example in the repo root.

Required variables

VariableRequiredWhere to find it
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY Yes Clerk Dashboard → API Keys
CLERK_SECRET_KEY Yes Clerk Dashboard → API Keys
DATABASE_URL Yes Neon Dashboard → Connection Details (pooled)
DIRECT_URL Yes Neon Dashboard → Connection Details (direct)

Pooled vs direct connection

DATABASE_URL uses the pooled Neon connection string (hostname includes -pooler). This is what your app uses at runtime — Prisma routes queries through Neon's PgBouncer-compatible pooler to prevent connection exhaustion on serverless functions.

DIRECT_URL is the direct (non-pooled) connection string. Prisma CLI needs this for schema operations (prisma migrate, prisma db push) that don't work through the pooler.

Note: Both URLs point to the same database. The only difference is whether the connection goes through the pooler or not. Set both correctly — missing DIRECT_URL will break migrations.

Full reference

For detailed setup guides, refer to the official documentation:

Optional keys

The starter also uses Stripe and Resend, but their keys are only required if you're using payments or email. Add them when needed:

  • STRIPE_SECRET_KEY — Stripe Dashboard
  • STRIPE_WEBHOOK_SECRET — Stripe CLI or Dashboard
  • RESEND_API_KEY — Resend Dashboard

Next: Authentication with Clerk →

Authentication (Clerk)

Authentication (Clerk)

Clerk is pre-configured in the starter. The pages, middleware, and components are already wired — here's how the setup works and how to customize it.

ClerkProvider

The app is wrapped in ClerkProvider in src/app/layout.tsx. This makes auth state available everywhere:

src/app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs'

export default function RootLayout({ children }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>{children}</body>
      </html>
    </ClerkProvider>
  )
}

Middleware

Route protection is configured in src/middleware.ts using clerkMiddleware():

src/middleware.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()

export const config = {
  matcher: [
    '/((?!_next|static|favicon.ico).*)',
  ],
}

To protect a specific route, use auth().protect() in your page or route handler:

src/app/dashboard/page.tsx
import { auth } from '@clerk/nextjs/server'

export default function DashboardPage() {
  const { userId } = auth()

  if (!userId) {
    return <div>Please sign in</div>
  }

  return <div>Dashboard content</div>
}

Auth components

Pre-built components for sign-in, sign-up, and user management are already set up in the starter:

  • SignInButton — renders a sign-in button that opens a modal
  • SignUpButton — renders a sign-up button that opens a modal
  • UserButton — renders the user menu (avatar, profile, sign-out)
  • SignOutButton — renders a sign-out button

Organizations & Billing

The starter includes Clerk Organizations for multi-tenant apps and Clerk Billing for subscriptions. Use OrganizationSwitcher for org switching and PricingTable for plan management.

The auth boundary lives in src/lib/auth/ — replace those exports if you want to switch to a different provider.

Full reference

Clerk Next.js Documentation


Next: Organizations (Multi-tenancy) →

Multi-tenancy

Organizations (Multi-tenancy)

Clerk Organizations provides built-in multi-tenancy — create organizations, invite members, assign roles, and scope data per org. Every resource is tagged with an orgId, and every query filters by it.

Enable Organizations in the Dashboard

Before you can use organizations, you must enable them in the Clerk Dashboard. This is a manual step — it can't be done from code:

  1. Go to Clerk Dashboard → your application → Organizations
  2. Toggle "Enable Organizations" on
  3. Configure settings (max members, admin roles, domain-based joining, etc.)

Add OrganizationSwitcher to the nav

Add the OrganizationSwitcher component to your app's navigation so users can create, switch between, and manage organizations:

src/components/org-switcher.tsx
import { OrganizationSwitcher } from '@clerk/nextjs'

export default function OrgSwitcher() {
  return (
    <OrganizationSwitcher
      afterCreateOrganizationUrl="/dashboard"
      afterLeaveOrganizationUrl="/"
    />
  )
}

Then add it to your layout or header component:

src/app/layout.tsx
import OrgSwitcher from '@/components/org-switcher'

export default function Layout({ children }) {
  return (
    <html>
      <body>
        <header>
          <OrgSwitcher />
          <UserButton />
        </header>
        {children}
      </body>
    </html>
  )
}

Org-scoped database queries

Every tenant-owned model in your Prisma schema needs an orgId column:

prisma/schema.prisma
model Project {
  id     String @id @default(cuid())
  orgId  String
  name   String

  @@index([orgId])
}

In your API routes and server actions, read orgId and orgRole from auth() and scope every query:

src/app/api/projects/route.ts
import { auth } from '@clerk/nextjs/server'
import prisma from '@/lib/prisma'

export async function GET() {
  const { orgId } = auth()

  if (!orgId) {
    return Response.json({ error: 'No organization selected' }, { status: 400 })
  }

  const projects = await prisma.project.findMany({
    where: { orgId }
  })

  return Response.json(projects)
}

Role-based access

Check orgRole for fine-grained authorization within an organization:

src/app/dashboard/admin/page.tsx
import { auth } from '@clerk/nextjs/server'

export default function AdminPage() {
  const { orgRole } = auth()

  if (orgRole !== 'org:admin') {
    return <div>Access denied. Admin only.</div>
  }

  return <div>Admin dashboard</div>
}

The pattern

Tag every resource with orgId, filter by orgId on every read. Keep this consistent everywhere — don't introduce a second pattern.

Full reference


Next: Database with Prisma + Neon →

Database (Prisma + Neon)

Database (Prisma + Neon)

The starter uses Prisma as the ORM and Neon as the Postgres provider. The schema is pre-defined and ready to migrate.

Schema

The default schema in prisma/schema.prisma includes three models:

  • User — maps to Clerk users, stores email, name, and image
  • Account — links users to their Stripe customer account
  • Subscription — tracks subscription status and plan details

Running migrations

Once your DATABASE_URL and DIRECT_URL are set in .env:

terminal
$ npx prisma migrate dev --name init

This creates the tables in your Neon database and generates the Prisma Client.

Prisma Client with Neon adapter

The client is instantiated in src/lib/prisma.ts using the Neon driver adapter for edge/serverless compatibility:

src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'
import { PrismaNeonHTTP } from '@prisma/adapter-neon'
import { Pool } from '@neondatabase/serverless'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaNeonHTTP(pool)
const prisma = new PrismaClient({ adapter })

export default prisma

The @prisma/adapter-neon package ensures queries work over HTTP with no persistent connections — critical for serverless environments.

Connection pooling

  • DATABASE_URL: Use the pooled connection string (hostname includes -pooler). Routes through Neon's connection pooler to prevent exhaustion on concurrent serverless invocations.
  • DIRECT_URL: Use the direct connection string. Prisma CLI needs this for migrate, db push, and studio — operations that don't work through the pooler.

Full reference


Next: UI with shadcn/ui + Tailwind →

UI (shadcn/ui + Tailwind)

UI (shadcn/ui + Tailwind)

The starter uses shadcn/ui for copy-in components and Tailwind CSS for styling. Both are pre-configured and ready to use.

Adding components

shadcn/ui components are copied into your project — not installed as a dependency. Add a new component with the CLI:

terminal
$ npx shadcn@latest add button card dialog

Components land in src/components/ui/. You own the code — customize freely.

Configuration

  • Tailwind config: tailwind.config.ts — theme tokens, CSS variable mappings, and content paths
  • Global CSS: src/app/globals.css — Tailwind directives and CSS variable definitions for light and dark mode
  • Components: src/components/ui/ — generated by the shadcn CLI

CSS variable mapping

The design tokens from the landing page and this documentation site map to Tailwind utilities:

CSS VariableTailwind UsageValue
--graphitebg-graphite, text-graphite#18181B
--indigobg-indigo, text-indigo#6D5EF5
--signalbg-signal, text-signal#3ECF8E
--slatetext-slate#71717A
--warm-whitebg-warm-white#FAFAFA

Dark mode

Dark mode is wired out of the box using Tailwind's class strategy. Toggle it by adding dark to the <html> element. CSS variables automatically switch values when .dark is present.

Full reference


Next: Internationalization →

Internationalization

Internationalization (next-intl)

The starter uses next-intl for internationalization — locale-based routing, translated UI, and a clean API for both Server and Client Components. A working Spanish locale is included as a demonstration.

Install next-intl

terminal
$ npm install next-intl

Route structure

Routes are restructured under a top-level [locale] dynamic segment:

src/
app/
  [locale]/
    layout.tsx   // wraps with NextIntlClientProvider
    page.tsx
    dashboard/
      page.tsx
  layout.tsx       // root layout (metadata, fonts)

The root app/layout.tsx handles metadata and fonts. The [locale]/layout.tsx wraps children in NextIntlClientProvider and sets the lang attribute.

Middleware

Add or update middleware.ts using next-intl's createMiddleware for locale detection and routing:

src/middleware.ts
import createMiddleware from 'next-intl/middleware'

export default createMiddleware({
  locales: ['en', 'es'],
  defaultLocale: 'en'
})

export const config = {
  matcher: ['/((?!_next|static|favicon.ico).*)']
}

Translation files

Create a messages/ directory with one JSON file per locale. Two files are included as examples:

messages/en.json
{
  "nav": {
    "home": "Home",
    "docs": "Docs",
    "github": "GitHub"
  },
  "hero": {
    "title": "Build your SaaS, ship it fast",
    "subtitle": "Pre-wired and production-ready",
    "cta": "Start Building Free"
  },
  "footer": {
    "copyright": "\u00a9 2026 boilerplate-saas \u00b7 MIT License"
  }
}
messages/es.json
{
  "nav": {
    "home": "Inicio",
    "docs": "Documentaci\u00f3n",
    "github": "GitHub"
  },
  "hero": {
    "title": "Construye tu SaaS, publ\u00edcalo r\u00e1pido",
    "subtitle": "Preconfigurado y listo para producci\u00f3n",
    "cta": "Empieza a construir gratis"
  },
  "footer": {
    "copyright": "\u00a9 2026 boilerplate-saas \u00b7 Licencia MIT"
  }
}

Server Components

Use getTranslations() from next-intl/server in your Server Components:

src/app/[locale]/page.tsx
import { getTranslations } from 'next-intl/server'

export default async function HomePage() {
  const t = await getTranslations('hero')

  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('subtitle')}</p>
    </div>
  )
}

Client Components

Use useTranslations() from next-intl in your Client Components:

src/components/nav.tsx
'use client'
import { useTranslations } from 'next-intl'

export default function Nav() {
  const t = useTranslations('nav')

  return (
    <nav>
      <a href="/">{t('home')}</a>
      <a href="/docs">{t('docs')}</a>
      <a href="https://github.com">{t('github')}</a>
    </nav>
  )
}

Locale switcher

Add a locale switcher to the navigation so users can switch languages:

src/components/locale-switcher.tsx
'use client'
import { useLocale } from 'next-intl'
import { usePathname, useRouter } from 'next/navigation'

export default function LocaleSwitcher() {
  const locale = useLocale()
  const pathname = usePathname()
  const router = useRouter()

  const switchLocale = (nextLocale: string) => {
    router.replace(`/${nextLocale}${pathname}`)
  }

  return (
    <select
      value={locale}
      onChange={(e) => switchLocale(e.target.value)}
    >
      <option value="en">EN</option>
      <option value="es">ES</option>
    </select>
  )
}

Static generation

Add generateStaticParams to your [locale]/layout.tsx so pages stay statically rendered where possible:

src/app/[locale]/layout.tsx
export function generateStaticParams() {
  return [{ locale: 'en' }, { locale: 'es' }]
}

Adding a new locale

To add another language:

  1. Add the locale code to the locales array in middleware.ts
  2. Create a new file in messages/ (e.g., fr.json) with all the same keys translated
  3. Add the locale to generateStaticParams()

Full reference


Next: Deployment →

Deployment

Deployment

The starter is optimized for Vercel but works on any platform that supports Next.js.

Deploy to Vercel

  1. Push your repository to GitHub
  2. Go to vercel.com and click Add New → Project
  3. Import your repository
  4. Add the required environment variables (see checklist below)
  5. Click Deploy

The default build settings work — no need to change the build command or output directory. Migrations run automatically on build via prisma migrate deploy in the build script.

Environment checklist

Set these variables in the Vercel dashboard before the first deploy:

VariableRequiredWhere to find it
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYYesClerk Dashboard → API Keys
CLERK_SECRET_KEYYesClerk Dashboard → API Keys
DATABASE_URLYesNeon → Connection Details (pooled)
DIRECT_URLYesNeon → Connection Details (direct)
STRIPE_SECRET_KEYFor paymentsStripe Dashboard
STRIPE_WEBHOOK_SECRETFor paymentsStripe CLI or Dashboard
RESEND_API_KEYFor emailResend Dashboard

Not on Vercel?

The build command is standard Next.js — deploy on Netlify, Railway, Fly.io, or your own server. Set the same environment variables and ensure Node.js >= 20.


Next: FAQ / Troubleshooting →

FAQ & Troubleshooting

FAQ / Troubleshooting

Common issues and questions from developers setting up the starter for the first time.

  • The first connection to a Neon database after it's been idle can take a few seconds. Add connect_timeout=10 to your DATABASE_URL:

    DATABASE_URL="postgresql://user:pass@ep-example-pooler.us-east-1.aws.neon.tech/neondb?connect_timeout=10"
  • Clerk uses different keys for development and production instances. When you deploy, create a new Clerk application or switch your existing one to production mode. The keys in your .env.local (development) won't work in production — update them in your Vercel dashboard environment variables.
  • Prisma CLI needs DIRECT_URL, not the pooled DATABASE_URL. The CLI ignores the connection pooler and connects directly. Make sure DIRECT_URL is set in your .env file.
  • Your app's domain isn't added to your Clerk application's allowed origins. In the Clerk Dashboard, go to your application settings and add your deployment URL (e.g., https://my-app.vercel.app) to the allowed origins list. For local development, http://localhost:3000 is added by default.
  • Make sure tailwind.config.ts includes the correct content paths:

    content: ['./src/**/*.{ts,tsx}']

    If components are in a different directory, add that path as well.