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

  • Thread starter Thread starter CodeWithDhanian
  • Start date Start date
C

CodeWithDhanian

Guest

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​


Code:
// 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

2. Setting Up the Development Environment​

Initializing Your Next.js Project​


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


Code:
npx create-next-app@latest my-saas-app --typescript --tailwind --eslint
cd my-saas-app

Essential Dependencies​


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


Code:
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

TypeScript Strict Configuration​


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


Code:
{
  "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"]
}

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​


Code:
// 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")
}

Initializing Prisma​


Set up Prisma with your database:


Code:
npx prisma init
npx prisma generate
npx prisma db push

4. Authentication Strategies​

Implementing Next-Auth.js​


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


Code:
// 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
    },
  },
})

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:

Code:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY={PUBLISHABLE_KEY}
CLERK_SECRET_KEY={SECRET_KEY}
NEXT_PUBLIC_CLERK_SIGN_IN_URL="/sign-in"

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:

Code:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

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:


Code:
// 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")
  }
}

Handling Stripe Webhooks​


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


Code:
// 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 })
}

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​


Code:
// 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}`)
  }
}

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:


Code:
// 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 }
    )
  }
}

Handling Timezones​


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


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

function convertToUTC(localDate: Date, timezone: string) {
  return zonedTimeToUtc(localDate, timezone)
}

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​


Code:
// 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 }

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​


Code:
// 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>
  )
}

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 :


Code:
{
  "global_cors": {
    "allow_origins_without_credentials": [
      "https://your-frontend-domain.vercel.app"
    ],
    "allow_origins_with_credentials": [
      "https://your-frontend-domain.vercel.app"
    ]
  }
}

Environment Variables​


Manage environment variables for different deployment stages:


Code:
# Development
ENCORE_ENV=development
# Production
ENCORE_ENV=production

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:


Code:
// 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,
})

12. Testing Strategy​

Implementing Comprehensive Tests​


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


Code:
// __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: "[email protected]",
    })

    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"
    )
  })
})

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!

Continue reading...
 


Join 𝕋𝕄𝕋 on Telegram
Channel PREVIEW:
Back
Top