Créer une Application SaaS avec Next.js : Le Guide Complet 2026
Brandon Sueur••15 min
Vous voulez créer un SaaS moderne en 2026 ? Next.js 15 + Stripe + Auth = combo gagnant.
Ce guide complet couvre l'architecture complète d'une application SaaS production-ready : authentification, paiements, multi-tenant, billing, webhooks, analytics et déploiement.
Temps de lecture : 15 min. Temps d'implémentation : 2-3 semaines.
TL;DR : Stack SaaS Moderne 2026
| Composant | Technologie | Pourquoi |
|---|---|---|
| Framework | Next.js 15 (App Router) | Full-stack, SEO, Server Components |
| Auth | Next-Auth v5 (Auth.js) | OAuth + Email, session JWT |
| Database | PostgreSQL + Drizzle ORM | Type-safe, migrations faciles |
| Payments | Stripe | Leader mondial, webhooks robustes |
| UI | shadcn/ui + Tailwind | Components React modernes |
| Resend | Developer-friendly, React Email | |
| Hosting | Vercel | Zero-config Next.js |
| Analytics | PostHog | Open-source, privacy-first |
Budget lancement : ~100€/mois (hébergement + services)
Architecture Globale : Vue d'Ensemble
┌─────────────────────────────────────────────────┐
│ FRONTEND (Next.js) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ Landing │ │Dashboard │ │ Billing Pages │ │
│ │Pages (SSR│ │ (Client) │ │ (Server) │ │
│ └──────────┘ └──────────┘ └───────────────┘ │
└─────────────┬───────────────────────────────────┘
│
┌─────────▼─────────┐
│ API Routes │
│ (Server Actions) │
└─────────┬─────────┘
│
┌─────────▼─────────┬──────────────┬───────────────┐
│ Next-Auth │ Stripe │ Database │
│ (Sessions) │ (Webhooks) │ (PostgreSQL) │
└───────────────────┴──────────────┴───────────────┘
Étape 1 : Setup Projet & Database
Installation Dépendances
# Créer projet Next.js
npx create-next-app@latest my-saas --typescript --tailwind --app
cd my-saas
# Installer dépendances essentielles
pnpm add next-auth@beta drizzle-orm postgres
pnpm add stripe @stripe/stripe-js
pnpm add resend react-email
pnpm add zod react-hook-form @hookform/resolvers
pnpm add @radix-ui/react-* # shadcn/ui components
# DevDependencies
pnpm add -D drizzle-kit
Schema Database (Drizzle ORM)
// db/schema.ts
import { pgTable, text, timestamp, uuid, integer, boolean, jsonb } from 'drizzle-orm/pg-core'
// ✅ Table Users
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: text('email').notNull().unique(),
name: text('name'),
avatar: text('avatar'),
emailVerified: timestamp('email_verified'),
createdAt: timestamp('created_at').defaultNow(),
})
// ✅ Table Organizations (Multi-tenant)
export const organizations = pgTable('organizations', {
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
logo: text('logo'),
plan: text('plan').default('free'), // free, pro, enterprise
stripeCustomerId: text('stripe_customer_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
subscriptionStatus: text('subscription_status'), // active, canceled, past_due
trialEndsAt: timestamp('trial_ends_at'),
createdAt: timestamp('created_at').defaultNow(),
})
// ✅ Table Memberships (Users ↔ Organizations)
export const memberships = pgTable('memberships', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id')
.references(() => users.id)
.notNull(),
organizationId: uuid('organization_id')
.references(() => organizations.id)
.notNull(),
role: text('role').notNull(), // owner, admin, member
createdAt: timestamp('created_at').defaultNow(),
})
// ✅ Table Subscriptions (Historique)
export const subscriptions = pgTable('subscriptions', {
id: uuid('id').defaultRandom().primaryKey(),
organizationId: uuid('organization_id')
.references(() => organizations.id)
.notNull(),
stripeSubscriptionId: text('stripe_subscription_id').unique(),
stripePriceId: text('stripe_price_id'),
status: text('status'), // active, canceled, past_due, trialing
currentPeriodStart: timestamp('current_period_start'),
currentPeriodEnd: timestamp('current_period_end'),
canceledAt: timestamp('canceled_at'),
createdAt: timestamp('created_at').defaultNow(),
})
// ✅ Migration
// pnpm drizzle-kit generate:pg
// pnpm drizzle-kit migrate
Étape 2 : Authentification (Next-Auth v5)
Configuration Next-Auth
// auth.config.ts
import type { NextAuthConfig } from 'next-auth'
import Google from 'next-auth/providers/google'
import GitHub from 'next-auth/providers/github'
import Resend from 'next-auth/providers/resend'
export const authConfig = {
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
Resend({
from: 'auth@myapp.com',
}),
],
pages: {
signIn: '/auth/signin',
signOut: '/auth/signout',
error: '/auth/error',
verifyRequest: '/auth/verify',
},
callbacks: {
async session({ session, token }) {
if (token.sub) {
session.user.id = token.sub
// ✅ Ajouter organization active
session.user.organizationId = token.organizationId as string
}
return session
},
async jwt({ token, user }) {
if (user) {
token.id = user.id
// ✅ Récupérer organization par défaut
const membership = await db.query.memberships.findFirst({
where: eq(memberships.userId, user.id),
with: { organization: true },
})
token.organizationId = membership?.organizationId
}
return token
},
},
} satisfies NextAuthConfig
// auth.ts
import NextAuth from 'next-auth'
import { authConfig } from './auth.config'
import { DrizzleAdapter } from '@auth/drizzle-adapter'
import { db } from '@/db'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
...authConfig,
session: { strategy: 'jwt' },
})
Page Sign In
// app/auth/signin/page.tsx
import { signIn } from '@/auth'
export default function SignInPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8">
<h1 className="text-3xl font-bold">Sign in to MyApp</h1>
{/* OAuth Providers */}
<form
action={async () => {
'use server'
await signIn('google', { redirectTo: '/dashboard' })
}}
>
<button className="w-full">Continue with Google</button>
</form>
<form
action={async () => {
'use server'
await signIn('github', { redirectTo: '/dashboard' })
}}
>
<button className="w-full">Continue with GitHub</button>
</form>
{/* Magic Link Email */}
<form
action={async (formData: FormData) => {
'use server'
await signIn('resend', {
email: formData.get('email') as string,
redirectTo: '/dashboard',
})
}}
>
<input type="email" name="email" placeholder="you@example.com" required />
<button type="submit">Continue with Email</button>
</form>
</div>
</div>
)
}
Étape 3 : Multi-Tenant Architecture
Middleware Organization Switcher
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
export async function middleware(request: NextRequest) {
const session = await auth()
// ✅ Protéger routes /dashboard
if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!session) {
return NextResponse.redirect(new URL('/auth/signin', request.url))
}
// ✅ Vérifier organization access
const orgSlug = request.nextUrl.pathname.split('/')[2]
if (orgSlug) {
const hasAccess = await checkOrganizationAccess(session.user.id, orgSlug)
if (!hasAccess) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
}
}
return NextResponse.next()
}
async function checkOrganizationAccess(userId: string, orgSlug: string) {
const membership = await db.query.memberships.findFirst({
where: and(eq(memberships.userId, userId), eq(organizations.slug, orgSlug)),
with: { organization: true },
})
return !!membership
}
Organization Switcher Component
// components/organization-switcher.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select'
type Organization = {
id: string
name: string
slug: string
plan: string
}
export function OrganizationSwitcher({
organizations,
currentOrg,
}: {
organizations: Organization[]
currentOrg: Organization
}) {
const router = useRouter()
return (
<Select
value={currentOrg.slug}
onValueChange={(slug) => {
router.push(`/dashboard/${slug}`)
}}
>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.id} value={org.slug}>
<div className="flex items-center gap-2">
<span>{org.name}</span>
<span className="text-xs text-muted-foreground">{org.plan}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
}
Étape 4 : Paiements Stripe
Configuration Stripe
// lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20.acacia',
typescript: true,
})
// Plans configuration
export const PLANS = {
free: {
name: 'Free',
price: 0,
limits: { users: 3, projects: 5 },
},
pro: {
name: 'Pro',
price: 29,
priceId: process.env.STRIPE_PRICE_ID_PRO,
limits: { users: 10, projects: 50 },
},
enterprise: {
name: 'Enterprise',
price: 99,
priceId: process.env.STRIPE_PRICE_ID_ENTERPRISE,
limits: { users: -1, projects: -1 }, // unlimited
},
} as const
Checkout Flow
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { stripe, PLANS } from '@/lib/stripe'
import { db } from '@/db'
export async function POST(request: NextRequest) {
const session = await auth()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { organizationId, plan } = await request.json()
// ✅ Récupérer organization
const organization = await db.query.organizations.findFirst({
where: eq(organizations.id, organizationId),
})
if (!organization) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
// ✅ Créer Stripe Customer si nécessaire
let stripeCustomerId = organization.stripeCustomerId
if (!stripeCustomerId) {
const customer = await stripe.customers.create({
email: session.user.email!,
metadata: {
organizationId: organization.id,
},
})
stripeCustomerId = customer.id
// Update organization
await db
.update(organizations)
.set({ stripeCustomerId: customer.id })
.where(eq(organizations.id, organizationId))
}
// ✅ Créer Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: PLANS[plan as keyof typeof PLANS].priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/${organization.slug}?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/${organization.slug}/billing`,
metadata: {
organizationId: organization.id,
},
})
return NextResponse.json({ url: checkoutSession.url })
}
Webhooks Stripe
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe'
import { db } from '@/db'
import Stripe from 'stripe'
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(request: NextRequest) {
const body = await request.text()
const signature = request.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (err) {
return NextResponse.json({ error: 'Webhook signature invalid' }, { status: 400 })
}
// ✅ Handle events
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
await handleCheckoutComplete(session)
break
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription
await handleSubscriptionUpdate(subscription)
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
await handleSubscriptionDeleted(subscription)
break
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice
await handlePaymentFailed(invoice)
break
}
}
return NextResponse.json({ received: true })
}
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
const organizationId = session.metadata?.organizationId
const subscriptionId = session.subscription as string
// Récupérer subscription complète
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
// ✅ Update organization
await db
.update(organizations)
.set({
stripeSubscriptionId: subscriptionId,
subscriptionStatus: 'active',
plan: subscription.items.data[0].price.lookup_key || 'pro',
})
.where(eq(organizations.id, organizationId))
// ✅ Créer entrée subscriptions
await db.insert(subscriptions).values({
organizationId,
stripeSubscriptionId: subscriptionId,
stripePriceId: subscription.items.data[0].price.id,
status: 'active',
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
})
}
async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
await db
.update(organizations)
.set({
subscriptionStatus: subscription.status,
})
.where(eq(organizations.stripeSubscriptionId, subscription.id))
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db
.update(organizations)
.set({
subscriptionStatus: 'canceled',
plan: 'free',
})
.where(eq(organizations.stripeSubscriptionId, subscription.id))
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
// ✅ Send email notification
await sendEmail({
to: invoice.customer_email!,
subject: 'Payment Failed',
template: 'payment-failed',
})
}
Billing Page
// app/dashboard/[orgSlug]/billing/page.tsx
import { auth } from '@/auth'
import { db } from '@/db'
import { BillingForm } from '@/components/billing-form'
export default async function BillingPage({ params }: { params: { orgSlug: string } }) {
const session = await auth()
const organization = await db.query.organizations.findFirst({
where: eq(organizations.slug, params.orgSlug),
with: {
subscription: true,
},
})
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold">Billing & Subscription</h1>
{/* Current Plan */}
<div className="rounded-lg border p-6">
<h2 className="text-xl font-semibold">Current Plan</h2>
<p className="text-3xl font-bold mt-2">{organization.plan}</p>
{organization.subscriptionStatus === 'active' && (
<p className="text-sm text-muted-foreground mt-1">
Next billing:{' '}
{new Date(organization.subscription.currentPeriodEnd).toLocaleDateString()}
</p>
)}
</div>
{/* Upgrade/Downgrade */}
<BillingForm organization={organization} />
</div>
)
}
Étape 5 : Email Transactionnel (Resend)
Configuration React Email
// emails/welcome.tsx
import { Html, Head, Body, Container, Heading, Text, Button } from '@react-email/components'
export default function WelcomeEmail({ name }: { name: string }) {
return (
<Html>
<Head />
<Body style={{ backgroundColor: '#f6f9fc' }}>
<Container style={{ padding: '20px' }}>
<Heading>Welcome to MyApp, {name}!</Heading>
<Text>
We're excited to have you on board. Get started by creating your first project.
</Text>
<Button
href="https://myapp.com/dashboard"
style={{
backgroundColor: '#5469d4',
color: '#fff',
padding: '12px 20px',
borderRadius: '5px',
}}
>
Go to Dashboard
</Button>
</Container>
</Body>
</Html>
)
}
// lib/email.ts
import { Resend } from 'resend'
import WelcomeEmail from '@/emails/welcome'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendWelcomeEmail(to: string, name: string) {
await resend.emails.send({
from: 'onboarding@myapp.com',
to,
subject: 'Welcome to MyApp!',
react: WelcomeEmail({ name }),
})
}
Étape 6 : Analytics & Monitoring
PostHog Integration
// lib/analytics.ts
import posthog from 'posthog-js'
export function initAnalytics() {
if (typeof window !== 'undefined') {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: 'https://app.posthog.com',
capture_pageview: false, // Manual pageview tracking
})
}
}
export function trackEvent(event: string, properties?: Record<string, any>) {
posthog.capture(event, properties)
}
// app/layout.tsx
'use client'
import { useEffect } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
import { initAnalytics } from '@/lib/analytics'
import posthog from 'posthog-js'
export default function RootLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
initAnalytics()
}, [])
useEffect(() => {
if (pathname) {
posthog.capture('$pageview')
}
}, [pathname, searchParams])
return <html>{children}</html>
}
Étape 7 : Déploiement Vercel
Configuration Production
# .env.production
DATABASE_URL=postgres://...
NEXTAUTH_URL=https://myapp.com
NEXTAUTH_SECRET=...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
RESEND_API_KEY=re_...
POSTHOG_KEY=phc_...
Vercel Deployment
# Install Vercel CLI
pnpm add -g vercel
# Deploy
vercel --prod
# Configure webhooks Stripe
# URL: https://myapp.com/api/webhooks/stripe
# Events: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed
Checklist Lancement SaaS
✅ Fonctionnalités Essentielles
- Authentification (OAuth + Email)
- Multi-tenant (Organizations)
- Billing Stripe (Checkout + Webhooks)
- Plans & Limits (Free, Pro, Enterprise)
- Email transactionnel
- Analytics utilisateurs
- Monitoring erreurs (Sentry)
✅ Sécurité
- CSRF protection (Next.js built-in)
- Rate limiting API routes
- Validation inputs (Zod)
- SQL injection protection (Drizzle parameterized queries)
- HTTPS only (Vercel auto)
✅ Performance
- Images optimisées (next/image)
- Caching API routes
- Database indexes
- CDN static assets
✅ SEO & Marketing
- Landing page optimisée
- Blog intégré
- Sitemap.xml
- robots.txt
- Open Graph images
Budget Mensuel Estimé
| Service | Plan | Prix |
|---|---|---|
| Vercel | Pro | 20€ |
| PostgreSQL | Neon Hobby | 0€ (puis 20€) |
| Stripe | Commission | 1.5% + 0.25€/transaction |
| Resend | Free → Pro | 0€ → 20€ |
| PostHog | Free | 0€ (jusqu'à 1M events) |
| Domain | .com | 12€/an |
| Total lancement | ~40-60€/mois |
Conclusion : SaaS en 2-3 Semaines
Avec cette architecture Next.js moderne, vous pouvez lancer un SaaS production-ready en 2-3 semaines :
- Semaine 1 : Auth + Database + Multi-tenant
- Semaine 2 : Stripe + Billing + Webhooks
- Semaine 3 : Polish UI + Déploiement
Chez Hulli Studio, nous créons des SaaS en 4-6 semaines (architecture + design + features spécifiques).
Articles connexes :