Créer une Application SaaS avec Next.js : Le Guide Complet 2026

Brandon Sueur15 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
Email 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 :