Créer une Application SaaS avec Next.js : Le Guide Complet 2026
Architecture complète d'une application SaaS avec Next.js 15 : de l'authentification aux paiements Stripe, en passant par le multi-tenant et le déploiement.
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 :
Brandon Sueur
Expert en développement web et création de produits numériques. Passionné par les technologies modernes et l'innovation, je partage mes connaissances et retours d'expérience pour aider les équipes à construire de meilleurs produits.
Articles similaires
Découvrez d'autres articles qui pourraient vous intéresser.
TypeScript Strict Mode ROI - Moins Bugs, Plus Productivité Développeurs 2026
TypeScript Strict ROI: -40% bugs production, +25% productivité, +60% confiance refactoring. Migration 15-45k€, break-even 8 mois.
React Hook Form : Le guide complet pour des formulaires performants en 2026
Maîtrisez React Hook Form pour créer des formulaires React performants et accessibles. Guide complet avec validation Zod, intégration TypeScript et patterns avancés.
Turbopack vs Webpack : Benchmark Complet et Migration Next.js 2026
Turbopack promet 10x plus de performance que Webpack. Benchmark réel sur 12 projets, guide de migration Next.js 15 et analyse technique approfondie.