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:
- Create a Clerk account and application
- 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:
- Create a Stripe account and obtain your API keys
- 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:
- Push your code to a GitHub repository
- Create a new project in Vercel and connect your repository
- Configure environment variables in the Vercel dashboard
- 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...