DEV Community

Cover image for How to Build Your First SaaS with Next.js, TypeScript, and Stripe
CodeWithDhanian
CodeWithDhanian

Posted on

How to Build Your First SaaS with Next.js, TypeScript, and Stripe

Introduction

Building a Software-as-a-Service (SaaS) application represents a significant milestone for any developer. The combination of Next.js 14, TypeScript, and Stripe provides a powerful foundation for creating robust, scalable, and production-ready SaaS products. This comprehensive guide will walk you through the entire process, from architectural decisions to implementation details, incorporating best practices and real-world examples. Whether you're building a social media management tool like Post Pilot , a habit-tracking application like Tends , or any other SaaS product, the principles outlined here will help you create a professional-grade application.

1. Architectural Overview

Why Next.js 14?

Next.js 14 has emerged as a full-stack framework that goes beyond traditional frontend development. Its App Router system allows developers to create "dashboard-like" applications with exceptional efficiency . The framework's built-in capabilities for server-side rendering, API routes, and server actions eliminate the need for a separate backend in many cases, streamlining development and reducing complexity.

TypeScript: Non-Negotiable for Professional Projects

The importance of strong typing cannot be overstated in production applications. TypeScript ensures that your codebase remains maintainable as it grows, facilitates team collaboration, and catches errors at compile time rather than runtime. As reported by developers who have built production SaaS applications, TypeScript is "a must" for any serious project .

Sample Next.js Configuration

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverActions: true,
  },
  images: {
    domains: ['your-s3-bucket.s3.amazonaws.com'],
  },
  typescript: {
    ignoreBuildErrors: false,
    strictNullChecks: true,
  },
}

module.exports = nextConfig
Enter fullscreen mode Exit fullscreen mode

2. Setting Up the Development Environment

Initializing Your Next.js Project

Begin by creating a new Next.js application with TypeScript support:

npx create-next-app@latest my-saas-app --typescript --tailwind --eslint
cd my-saas-app
Enter fullscreen mode Exit fullscreen mode

Essential Dependencies

Install the necessary dependencies for a full-stack SaaS application:

npm install stripe @stripe/stripe-js @stripe/react-stripe-js
npm install prisma @prisma/client
npm install next-auth # or your authentication library of choice
npm install date-fns # for date manipulation
npm install @types/node @types/react # additional TypeScript support
Enter fullscreen mode Exit fullscreen mode

TypeScript Strict Configuration

Enable strict mode in your tsconfig.json to maximize type safety:

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "strictNullChecks": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve"
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}
Enter fullscreen mode Exit fullscreen mode

3. Database Design with Prisma and PostgreSQL

Why SQL Over NoSQL?

For most SaaS applications, SQL databases like PostgreSQL are preferable due to their robust support for relationships between entities. As experienced developers note, "99% of all apps out there have some kind of a reference, users to posts, posts to comments etc." . PostgreSQL's support for timezones and date-related features makes it particularly valuable for applications dealing with scheduling and international users.

Sample User Schema

// schema.prisma
model User {
  id                   String     @id @default(cuid())
  name                 String?
  email                String?    @unique
  image                String?
  emailVerified        DateTime?  @map("email_verified")
  stripeCustomerId     String?    @unique @map("stripe_customer_id")
  stripeSubscriptionId String?    @unique @map("stripe_subscription_id")
  accounts             Account[]
  posts                Post[]
  sessions             Session[]
  Platform             Platform[]

  @@map("users")
}

model Account {
  id                 String  @id @default(cuid())
  userId             String  @map("user_id")
  type               String
  provider           String
  providerAccountId  String  @map("provider_account_id")
  refresh_token      String?
  access_token       String?
  expires_at         Int?
  token_type         String?
  scope              String?
  id_token           String?
  session_state      String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@map("accounts")
}
Enter fullscreen mode Exit fullscreen mode

Initializing Prisma

Set up Prisma with your database:

npx prisma init
npx prisma generate
npx prisma db push
Enter fullscreen mode Exit fullscreen mode

4. Authentication Strategies

Implementing Next-Auth.js

Next-Auth provides a complete authentication solution for Next.js applications. Here's a basic configuration:

// pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth"
import GithubProvider from "next-auth/providers/github"
import GoogleProvider from "next-auth/providers/google"

export default NextAuth({
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
    GoogleProvider({
      clientId: process.env.GOOGLE_ID,
      clientSecret: process.env.GOOGLE_SECRET,
    }),
  ],
  callbacks: {
    async session({ session, token, user }) {
      session.user.id = token.sub
      return session
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Alternative: Clerk Authentication

Some SaaS templates use Clerk for authentication . To set up Clerk:

  1. Create a Clerk account and application
  2. Configure your environment variables:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY={PUBLISHABLE_KEY}
CLERK_SECRET_KEY={SECRET_KEY}
NEXT_PUBLIC_CLERK_SIGN_IN_URL="/sign-in"
Enter fullscreen mode Exit fullscreen mode

5. Payment Integration with Stripe

Setting Up Stripe

Stripe is the payment processor of choice for many SaaS applications due to its developer-friendly API and comprehensive documentation . To get started:

  1. Create a Stripe account and obtain your API keys
  2. Set up environment variables:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
Enter fullscreen mode Exit fullscreen mode

Implementing Server Actions for Payments

Next.js Server Actions allow you to handle payment processing in a single request-response cycle . Here's an example:

// app/actions/stripeActions.ts
"use server"

import { stripe } from "@/lib/stripe"
import { currentUser } from "@/lib/auth"
import { redirect } from "next/navigation"

export async function createCheckoutSession(priceId: string) {
  const user = await currentUser()

  if (!user) {
    throw new Error("You must be logged in to purchase a subscription")
  }

  let stripeCustomerId = user.stripeCustomerId

  // Create a new Stripe customer if one doesn't exist
  if (!stripeCustomerId) {
    const customer = await stripe.customers.create({
      email: user.email,
      metadata: {
        userId: user.id,
      },
    })

    stripeCustomerId = customer.id
    // Save stripeCustomerId to your database
  }

  const session = await stripe.checkout.sessions.create({
    customer: stripeCustomerId,
    line_items: [
      {
        price: priceId,
        quantity: 1,
      },
    ],
    mode: "subscription",
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
  })

  if (session.url) {
    redirect(session.url)
  } else {
    throw new Error("Failed to create checkout session")
  }
}
Enter fullscreen mode Exit fullscreen mode

Handling Stripe Webhooks

Webhooks are essential for handling events like subscription changes and payments:

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server"
import { stripe } from "@/lib/stripe"
import { headers } from "next/headers"

export async function POST(req: NextRequest) {
  const body = await req.text()
  const signature = headers().get("stripe-signature")!

  let event: stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (error) {
    return new NextResponse(`Webhook Error: ${error.message}`, { status: 400 })
  }

  switch (event.type) {
    case "checkout.session.completed":
      const session = event.data.object
      // Update user subscription status in database
      break
    case "customer.subscription.updated":
      const subscription = event.data.object
      // Handle subscription changes
      break
    case "customer.subscription.deleted":
      const deletedSubscription = event.data.object
      // Handle subscription cancellation
      break
    default:
      console.log(`Unhandled event type ${event.type}`)
  }

  return new NextResponse(null, { status: 200 })
}
Enter fullscreen mode Exit fullscreen mode

Testing Payments

Use Stripe's test cards to validate your payment flow without processing real transactions :

  • Card Number: 4242 4242 4242 4242
  • Expiration: Any future date
  • CVC: Any 3-digit number

6. File Storage with AWS S3

Why S3?

AWS S3 provides cost-effective and scalable storage for user uploads like images and documents. As noted by SaaS developers, "S3 is kind of industry standard for storing and retrieving images" .

Implementation Example

// lib/s3.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"

const s3Client = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
})

export async function uploadFileToS3(
  fileBuffer: Buffer,
  fileName: string,
  contentType: string
) {
  const params = {
    Bucket: process.env.S3_BUCKET_NAME,
    Key: fileName,
    Body: fileBuffer,
    ContentType: contentType,
  }

  try {
    const command = new PutObjectCommand(params)
    await s3Client.send(command)
    return `https://${process.env.S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${fileName}`
  } catch (error) {
    throw new Error(`Failed to upload file to S3: ${error}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

7. Cron Jobs for Scheduled Functionality

Implementing Scheduled Tasks

Many SaaS applications require background tasks, such as publishing scheduled posts . With Vercel's Pro plan, you can trigger route handlers at specified intervals:

// app/api/cron/publish-scheduled-posts/route.ts
import { NextRequest, NextResponse } from "next/server"
import { publishScheduledPosts } from "@/lib/publishPosts"

export async function GET(req: NextRequest) {
  const authHeader = req.headers.get("authorization")

  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return new NextResponse("Unauthorized", { status: 401 })
  }

  try {
    await publishScheduledPosts()
    return NextResponse.json({ success: true })
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to publish scheduled posts" },
      { status: 500 }
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

Handling Timezones

Dealing with timezones is a common challenge in SaaS applications. The date-fns-tz package can help manage timezone conversions:

import { format, zonedTimeToUtc } from "date-fns-tz"

function convertToUTC(localDate: Date, timezone: string) {
  return zonedTimeToUtc(localDate, timezone)
}
Enter fullscreen mode Exit fullscreen mode

8. UI Components with shadcn/ui and Tailwind CSS

Consistent UI Development

Using a component library like shadcn/ui with Tailwind CSS ensures a consistent design system while maintaining development efficiency . These libraries provide pre-built components that can be easily customized to match your brand.

Example Component Implementation

// components/ui/button.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline: "border border-input hover:bg-accent hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "underline-offset-4 hover:underline text-primary",
      },
      size: {
        default: "h-10 py-2 px-4",
        sm: "h-9 px-3 rounded-md",
        lg: "h-11 px-8 rounded-md",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, ...props }, ref) => {
    return (
      <button
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }
Enter fullscreen mode Exit fullscreen mode

9. Error Handling and Monitoring

Implementing Robust Error Handling

Production applications require comprehensive error handling. Next.js provides an error.tsx file for granular error display , while tools like Sentry offer advanced monitoring capabilities.

Example Error Boundary

// app/dashboard/error.tsx
"use client"

import { useEffect } from "react"

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  useEffect(() => {
    // Log the error to an error reporting service
    console.error(error)
  }, [error])

  return (
    <div>
      <h2>Something went wrong!</h2>
      <button
        onClick={
          // Attempt to recover by trying to re-render the segment
          () => reset()
        }
      >
        Try again
      </button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

10. Deployment and Production Readiness

Deploying to Vercel

Vercel provides seamless deployment for Next.js applications:

  1. Push your code to a GitHub repository
  2. Create a new project in Vercel and connect your repository
  3. Configure environment variables in the Vercel dashboard
  4. Deploy

CORS Configuration

Ensure your backend and frontend can communicate properly by configuring CORS in your encore.app file :

{
  "global_cors": {
    "allow_origins_without_credentials": [
      "https://your-frontend-domain.vercel.app"
    ],
    "allow_origins_with_credentials": [
      "https://your-frontend-domain.vercel.app"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Environment Variables

Manage environment variables for different deployment stages:

# Development
ENCORE_ENV=development
# Production
ENCORE_ENV=production
Enter fullscreen mode Exit fullscreen mode

11. Security Best Practices

Protecting Sensitive Data

When dealing with payments or private data, never store sensitive information directly in your database. "Store just some references like IDs and always make new request for fresh data" .

Implementing Rate Limiting

Protect your API endpoints from abuse by implementing rate limiting:

// lib/rateLimit.ts
import { LRUCache } from 'lru-cache'

const rateLimit = (options: {
  uniqueTokenPerInterval: number
  interval: number
}) => {
  const tokenCache = new LRUCache({
    max: options.uniqueTokenPerInterval,
    ttl: options.interval,
  })

  return {
    check: (res: NextResponse, limit: number, token: string) =>
      new Promise<void>((resolve, reject) => {
        const tokenCount = (tokenCache.get(token) as number[]) || [0]
        if (tokenCount[0] === 0) {
          tokenCache.set(token, tokenCount)
        }
        tokenCount[0] += 1

        const currentUsage = tokenCount[0]
        const isRateLimited = currentUsage >= limit

        res.headers.set('X-RateLimit-Limit', limit.toString())
        res.headers.set(
          'X-RateLimit-Remaining',
          isRateLimited ? '0' : (limit - currentUsage).toString()
        )

        return isRateLimited ? reject() : resolve()
      }),
  }
}

const limiter = rateLimit({
  uniqueTokenPerInterval: 500,
  interval: 60000,
})
Enter fullscreen mode Exit fullscreen mode

12. Testing Strategy

Implementing Comprehensive Tests

Ensure your application works as expected with a combination of unit, integration, and end-to-end tests:

// __tests__/payment.test.ts
import { createCheckoutSession } from "@/app/actions/stripeActions"
import { currentUser } from "@/lib/auth"

jest.mock("@/lib/auth")

describe("Payment Processing", () => {
  it("should create a checkout session for authenticated users", async () => {
    ;(currentUser as jest.Mock).mockResolvedValue({
      id: "user_123",
      email: "test@example.com",
    })

    const session = await createCheckoutSession("price_123")
    expect(session).toHaveProperty("url")
  })

  it("should throw an error for unauthenticated users", async () => {
    ;(currentUser as jest.Mock).mockResolvedValue(null)

    await expect(createCheckoutSession("price_123")).rejects.toThrow(
      "You must be logged in to purchase a subscription"
    )
  })
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

Building a SaaS application with Next.js 14, TypeScript, and Stripe provides a robust foundation for creating scalable, production-ready products. By following the architecture and implementation details outlined in this guide, you'll be well-equipped to create your own SaaS application that handles authentication, payments, file storage, and scheduled tasks efficiently.

Remember that successful SaaS development involves not just technical implementation but also thoughtful consideration of user experience, security, and maintainability. Continuously test your application, monitor its performance, and iterate based on user feedback.

Further Learning

To deepen your understanding of SaaS development with Next.js and TypeScript, I recommend checking out the comprehensive ebook "Advanced SaaS Development with Next.js and TypeScript" available at https://codewithdhanian.gumroad.com/l/lykzu. This resource provides additional insights, advanced patterns, and real-world examples that will help you master SaaS development and build applications that stand out in the competitive market.

Whether you're building your first SaaS product or looking to enhance your existing development skills, continuous learning and practical application of these concepts will lead to successful outcomes. Happy coding!

Top comments (0)