Building a Software-as-a-Service application is one of the most rewarding challenges in modern software engineering. You need a responsive, interactive frontend that delights users, a rock-solid backend that handles business logic and data persistence, and a billing system that actually collects revenue. In this guide, we walk through building a production-grade SaaS platform using Next.js on the frontend and .NET on the backend, two technologies that complement each other remarkably well for full-stack SaaS development.
Whether you are launching a new product or migrating an existing monolith into a proper SaaS architecture, this article covers the critical decisions and implementation details you need to ship with confidence.
Choosing Your SaaS Architecture
Before writing a single line of code, you need to answer a few foundational questions that will shape every layer of your stack.
Multi-Tenancy Model. The biggest architectural decision for any SaaS product is how you isolate tenant data. The three common approaches are:
- Shared database, shared schema -- All tenants share the same tables, distinguished by a
TenantIdcolumn. This is the simplest to operate and the most cost-effective at scale. - Shared database, separate schemas -- Each tenant gets their own database schema within a single database instance. Better isolation, moderate complexity.
- Separate databases -- Each tenant gets a dedicated database. Maximum isolation, highest operational overhead.
For most startups and mid-market SaaS products, the shared-database-shared-schema approach strikes the right balance. It keeps infrastructure costs low while still allowing logical isolation through query filters. That is the model we will use throughout this guide.
Authentication and Authorization. Every SaaS needs user authentication, role-based access control, and organization (tenant) management. On the .NET side, ASP.NET Core Identity gives you a battle-tested foundation. On the frontend, Next.js middleware combined with JWT tokens provides seamless route protection.
Billing. Stripe is the de facto standard for SaaS billing. Its APIs cover subscriptions, metered usage, invoicing, and customer portal management. We will integrate Stripe on both the backend (webhook processing, subscription management) and the frontend (checkout sessions, billing portal).
With these decisions made, let us start building.
Next.js Frontend: App Router, Server Components, and Dashboard Layouts
Next.js with the App Router is an excellent choice for SaaS frontends. Server Components reduce client-side JavaScript, the built-in layouts system maps perfectly to dashboard shell patterns, and server actions simplify form handling.
Start by setting up a layout structure that separates your marketing pages from your authenticated dashboard:
app/
├── (marketing)/
│ ├── layout.tsx # Public layout with navbar and footer
│ ├── page.tsx # Landing page
│ └── pricing/page.tsx # Pricing page
├── (dashboard)/
│ ├── layout.tsx # Authenticated layout with sidebar
│ ├── page.tsx # Dashboard home
│ ├── settings/page.tsx # Organization settings
│ └── billing/page.tsx # Billing management
├── api/
│ └── webhooks/
│ └── stripe/route.ts
└── middleware.ts # Auth guard for dashboard routes
The dashboard layout provides the authenticated shell that wraps every page behind the login wall:
// app/(dashboard)/layout.tsx
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';
import Sidebar from '@/components/dashboard/Sidebar';
import TopBar from '@/components/dashboard/TopBar';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session) redirect('/login');
return (
<div className="flex h-screen bg-gray-50">
<Sidebar
user={session.user}
organization={session.organization}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<TopBar user={session.user} />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
);
}
For data fetching, Server Components let you call your .NET API directly from the server without exposing endpoints to the client:
// app/(dashboard)/page.tsx
import { fetchFromApi } from '@/lib/api';
interface DashboardMetrics {
activeUsers: number;
monthlyRevenue: number;
openTickets: number;
}
export default async function DashboardPage() {
const metrics = await fetchFromApi<DashboardMetrics>(
'/api/dashboard/metrics'
);
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<MetricCard title="Active Users" value={metrics.activeUsers} />
<MetricCard title="Monthly Revenue" value={`$${metrics.monthlyRevenue}`} />
<MetricCard title="Open Tickets" value={metrics.openTickets} />
</div>
);
}
Middleware handles route protection at the edge, ensuring unauthenticated users never see dashboard content:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
const isDashboard = request.nextUrl.pathname.startsWith('/dashboard');
if (isDashboard && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*'],
};
.NET Backend: Clean Architecture with EF Core and Identity
The .NET backend is where your business logic, data access, and integrations live. Clean Architecture keeps things organized as your SaaS grows from a handful of features to hundreds.
Structure your solution into distinct projects:
SaaSPlatform/
├── SaaSPlatform.Api/ # ASP.NET Core Web API
├── SaaSPlatform.Application/ # Use cases, DTOs, interfaces
├── SaaSPlatform.Domain/ # Entities, value objects, domain events
├── SaaSPlatform.Infrastructure/ # EF Core, email, file storage, Stripe
└── SaaSPlatform.Tests/ # Unit and integration tests
For multi-tenancy, define a global query filter on every tenant-scoped entity. This ensures that a tenant can never accidentally read another tenant's data:
// SaaSPlatform.Infrastructure/Data/AppDbContext.cs
public class AppDbContext : IdentityDbContext<ApplicationUser>
{
private readonly ITenantProvider _tenantProvider;
public AppDbContext(
DbContextOptions<AppDbContext> options,
ITenantProvider tenantProvider)
: base(options)
{
_tenantProvider = tenantProvider;
}
public DbSet<Project> Projects => Set<Project>();
public DbSet<Organization> Organizations => Set<Organization>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Apply global tenant filter to all tenant-scoped entities
builder.Entity<Project>()
.HasQueryFilter(p => p.OrganizationId == _tenantProvider.OrganizationId);
}
public override Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
// Automatically set OrganizationId on new entities
foreach (var entry in ChangeTracker.Entries<ITenantEntity>()
.Where(e => e.State == EntityState.Added))
{
entry.Entity.OrganizationId = _tenantProvider.OrganizationId;
}
return base.SaveChangesAsync(cancellationToken);
}
}
The ITenantProvider resolves the current tenant from the authenticated user's claims:
// SaaSPlatform.Infrastructure/Services/TenantProvider.cs
public class TenantProvider : ITenantProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
public TenantProvider(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Guid OrganizationId =>
Guid.Parse(_httpContextAccessor.HttpContext!
.User.FindFirstValue("org_id")!);
}
For API endpoints, use Minimal APIs for straightforward CRUD or Controllers for more complex operations. Here is a typical endpoint for listing projects within the current tenant:
// SaaSPlatform.Api/Endpoints/ProjectEndpoints.cs
app.MapGet("/api/projects", async (AppDbContext db) =>
{
// The global query filter automatically scopes to the current tenant
var projects = await db.Projects
.OrderByDescending(p => p.CreatedAt)
.Select(p => new ProjectDto(p.Id, p.Name, p.Status, p.CreatedAt))
.ToListAsync();
return Results.Ok(projects);
})
.RequireAuthorization();
Connecting Frontend and Backend
With both sides built, you need a clean integration layer. Create a typed API client on the Next.js side that handles authentication, error mapping, and type safety:
// lib/api.ts
const API_BASE = process.env.API_URL || 'http://localhost:5000';
export async function fetchFromApi<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const { cookies } = await import('next/headers');
const cookieStore = await cookies();
const token = cookieStore.get('auth-token')?.value;
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
next: { revalidate: 60 },
});
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
For mutations, Next.js Server Actions provide an elegant pattern that avoids exposing API details to the client:
// app/(dashboard)/projects/actions.ts
'use server';
import { fetchFromApi } from '@/lib/api';
import { revalidatePath } from 'next/cache';
export async function createProject(formData: FormData) {
const name = formData.get('name') as string;
const description = formData.get('description') as string;
await fetchFromApi('/api/projects', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
revalidatePath('/dashboard/projects');
}
This pattern keeps your .NET API as the single source of truth for business logic while giving the Next.js frontend a seamless developer experience.
Stripe Integration for SaaS Billing
Billing is where your SaaS actually becomes a business. Stripe handles the heavy lifting, but you still need to wire it up on both sides.
On the .NET backend, create a service that manages subscriptions:
// SaaSPlatform.Infrastructure/Services/BillingService.cs
public class BillingService : IBillingService
{
private readonly StripeClient _stripe;
private readonly AppDbContext _db;
public BillingService(AppDbContext db, IConfiguration config)
{
_stripe = new StripeClient(config["Stripe:SecretKey"]);
_db = db;
}
public async Task<string> CreateCheckoutSession(
Guid organizationId, string priceId, string successUrl, string cancelUrl)
{
var org = await _db.Organizations.FindAsync(organizationId)
?? throw new NotFoundException("Organization not found");
var options = new Stripe.Checkout.SessionCreateOptions
{
Customer = org.StripeCustomerId,
Mode = "subscription",
LineItems = new List<Stripe.Checkout.SessionLineItemOptions>
{
new() { Price = priceId, Quantity = 1 }
},
SuccessUrl = successUrl,
CancelUrl = cancelUrl,
};
var service = new Stripe.Checkout.SessionService(_stripe);
var session = await service.CreateAsync(options);
return session.Url;
}
}
Handle Stripe webhooks to keep your database in sync with subscription changes:
app.MapPost("/api/webhooks/stripe", async (
HttpContext context, AppDbContext db, IConfiguration config) =>
{
var json = await new StreamReader(context.Request.Body).ReadToEndAsync();
var stripeEvent = EventUtility.ConstructEvent(
json,
context.Request.Headers["Stripe-Signature"],
config["Stripe:WebhookSecret"]
);
switch (stripeEvent.Type)
{
case "customer.subscription.updated":
var subscription = stripeEvent.Data.Object as Subscription;
await UpdateOrganizationPlan(db, subscription!);
break;
case "customer.subscription.deleted":
var canceled = stripeEvent.Data.Object as Subscription;
await HandleCancellation(db, canceled!);
break;
}
return Results.Ok();
});
On the Next.js frontend, the billing page lets users manage their subscription through Stripe's Customer Portal:
// app/(dashboard)/billing/page.tsx
import { fetchFromApi } from '@/lib/api';
export default async function BillingPage() {
const billing = await fetchFromApi<{
plan: string;
status: string;
currentPeriodEnd: string;
portalUrl: string;
}>('/api/billing/current');
return (
<div className="max-w-2xl">
<h1 className="text-2xl font-bold mb-6">Billing</h1>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<div>
<p className="text-sm text-gray-500">Current Plan</p>
<p className="text-xl font-semibold">{billing.plan}</p>
</div>
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
{billing.status}
</span>
</div>
<p className="text-sm text-gray-500 mb-6">
Renews on {new Date(billing.currentPeriodEnd).toLocaleDateString()}
</p>
<a
href={billing.portalUrl}
className="btn-primary inline-block"
>
Manage Subscription
</a>
</div>
</div>
);
}
Deployment Strategies for Production
Getting your full-stack SaaS into production requires thoughtful deployment planning. Here is a proven approach:
Frontend (Next.js). Deploy to Vercel for the simplest path, or containerize with Docker and deploy to any cloud provider. Vercel gives you edge caching, automatic preview deployments for PRs, and zero-config scaling. If you need more control, a Docker deployment on Railway or AWS ECS works well.
Backend (.NET). Containerize your API with a multi-stage Dockerfile:
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["SaaSPlatform.Api/SaaSPlatform.Api.csproj", "SaaSPlatform.Api/"]
COPY ["SaaSPlatform.Application/SaaSPlatform.Application.csproj", "SaaSPlatform.Application/"]
COPY ["SaaSPlatform.Domain/SaaSPlatform.Domain.csproj", "SaaSPlatform.Domain/"]
COPY ["SaaSPlatform.Infrastructure/SaaSPlatform.Infrastructure.csproj", "SaaSPlatform.Infrastructure/"]
RUN dotnet restore "SaaSPlatform.Api/SaaSPlatform.Api.csproj"
COPY . .
RUN dotnet publish "SaaSPlatform.Api/SaaSPlatform.Api.csproj" \
-c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "SaaSPlatform.Api.dll"]
Database. Use a managed PostgreSQL instance from your cloud provider. Apply EF Core migrations as part of your CI/CD pipeline, never manually in production. A migration step in your deployment script ensures your schema stays in sync:
dotnet ef database update --project SaaSPlatform.Infrastructure \
--startup-project SaaSPlatform.Api \
--connection "$DATABASE_URL"
Environment Configuration. Keep secrets out of your codebase. Use environment variables for connection strings, API keys, and Stripe credentials. Both Vercel and Railway support encrypted environment variables with per-environment overrides.
CI/CD. A GitHub Actions workflow that builds, tests, and deploys both sides ensures consistent releases:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- run: dotnet test
- run: dotnet publish -c Release
# Deploy to Railway or your hosting provider
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run build
# Vercel auto-deploys from main, or deploy manually
Bringing It All Together
Building a SaaS application with Next.js and .NET gives you the best of both ecosystems: a modern, fast frontend with server-side rendering and a type-safe, performant backend with a mature ecosystem for data access, authentication, and enterprise patterns. The combination scales from your first paying customer to thousands of tenants without requiring a rewrite.
The key takeaways for successful SaaS development are: start with a clear multi-tenancy strategy, enforce tenant isolation at the data layer, use Server Components to reduce client complexity, and integrate billing early so you can validate your pricing model alongside your product.
If you are planning a SaaS product and want experienced guidance on architecture, implementation, or scaling, our team at Maranatha Technologies specializes in exactly this kind of full-stack web development and custom software engineering. We would be happy to discuss your project and help you build something that lasts. Get in touch and let us know what you are working on.