Building a SaaS Product With Nuxt: The Architecture That Works
Nuxt is often described as a frontend framework. That undersells it. With Nuxt's built-in server layer (Nitro), you can build and deploy a complete SaaS product — frontend, API, webhooks, and background jobs — from a single codebase.
Here's the architecture we use for SaaS products, and the decisions behind it.
The full stack
Frontend (Vue + Nuxt)
└── pages/ — All user-facing routes
└── components/ — Reusable UI components
└── composables/ — Shared logic
└── stores/ — Pinia state (auth, UI state)
Server (Nitro)
└── server/api/ — REST API routes
└── server/middleware/ — Auth validation, rate limiting
└── server/plugins/ — Server-side plugins (DB init, etc.)
└── server/utils/ — Shared server utilities
Services
└── Supabase — Database + Auth + Storage + Realtime
└── Stripe — Billing + Subscriptions
└── Resend / Postmark — Transactional email
└── Vercel — Deployment + Edge network
This is intentionally minimal. No separate backend service. No API gateway. No microservices. One repo, one deploy, one team to maintain it.
Authentication with Supabase Auth
Supabase Auth handles sign-up, login, OAuth (Google, GitHub, etc.), magic links, and session management.
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
const publicRoutes = ['/', '/pricing', '/blog', '/api/stripe-webhook']
if (publicRoutes.some(route => event.path.startsWith(route))) return
const session = await getServerSession(event)
if (!session) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
event.context.user = session.user
})
// composables/useAuth.ts
export function useAuth() {
const supabase = useSupabaseClient()
const user = useSupabaseUser()
async function signIn(email: string, password: string) {
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) throw error
await navigateTo('/app')
}
async function signOut() {
await supabase.auth.signOut()
await navigateTo('/')
}
return { user, signIn, signOut }
}
Database with Supabase Postgres
Supabase gives you a full Postgres database with row-level security, migrations, and a generated TypeScript client.
Define your schema with Supabase migrations:
-- migrations/001_initial.sql
create table workspaces (
id uuid primary key default gen_random_uuid(),
name text not null,
created_at timestamptz default now()
);
create table workspace_members (
workspace_id uuid references workspaces(id) on delete cascade,
user_id uuid references auth.users(id) on delete cascade,
role text not null default 'member',
primary key (workspace_id, user_id)
);
-- Row Level Security
alter table workspaces enable row level security;
create policy "Users can see their workspaces" on workspaces
using (id in (
select workspace_id from workspace_members where user_id = auth.uid()
));
Access from server routes:
// server/api/workspaces.get.ts
export default defineEventHandler(async (event) => {
const user = event.context.user
const supabase = serverSupabaseClient(event)
const { data: workspaces } = await supabase
.from('workspaces')
.select('*, workspace_members!inner(role)')
.eq('workspace_members.user_id', user.id)
return workspaces
})
Billing with Stripe
Stripe handles subscriptions, payment methods, invoices, and customer management.
The key integration points:
Checkout: Direct users to Stripe-hosted checkout for subscription start. On success, Stripe redirects back with a session ID.
// server/api/create-checkout.post.ts
export default defineEventHandler(async (event) => {
const user = event.context.user
const { priceId } = await readBody(event)
const session = await stripe.checkout.sessions.create({
customer_email: user.email,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.BASE_URL}/app?upgraded=true`,
cancel_url: `${process.env.BASE_URL}/pricing`,
metadata: { userId: user.id }
})
return { url: session.url }
})
Webhooks: Stripe sends events to your webhook endpoint for subscription changes:
// server/api/stripe-webhook.post.ts
export default defineEventHandler(async (event) => {
const body = await readRawBody(event)
const sig = getHeader(event, 'stripe-signature')!
const stripeEvent = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
if (stripeEvent.type === 'customer.subscription.updated') {
const sub = stripeEvent.data.object
await updateUserSubscription(sub.metadata.userId, sub.status)
}
return { received: true }
})
Multi-tenancy
Most SaaS products are multi-tenant: multiple customers each with their own data. The workspace/organisation pattern handles this:
- Each user belongs to one or more workspaces
- All resources (projects, files, settings) belong to a workspace
- Row-level security ensures users only see data for their workspaces
The workspace context is available throughout the app:
// composables/useWorkspace.ts
export function useWorkspace() {
const route = useRoute()
const workspaceId = computed(() => route.params.workspaceId as string)
const { data: workspace } = useFetch(() =>
`/api/workspaces/${workspaceId.value}`,
{ watch: [workspaceId] }
)
return { workspace, workspaceId }
}
Transactional email (invitations, password resets, notifications) goes through Resend or Postmark:
// server/utils/email.ts
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendInvitationEmail(
recipientEmail: string,
inviterName: string,
workspaceName: string,
invitationToken: string
) {
await resend.emails.send({
from: 'notifications@yoursaas.com',
to: recipientEmail,
subject: `${inviterName} invited you to ${workspaceName}`,
react: InvitationEmail({ inviterName, workspaceName, invitationToken })
})
}
Why this stack for SaaS
Nuxt server routes eliminate a separate API service. Same codebase, same deployment, same type safety end-to-end.
Supabase provides database, auth, realtime, and storage in a single managed service. The alternative — separate Postgres + Auth provider + storage bucket + connection pooler — is significantly more operational overhead.
Stripe is the only serious option for SaaS billing. Its webhook reliability and subscription management APIs are unmatched.
Vercel makes deployment trivial. SSR, edge functions, and preview deployments for every PR — zero configuration.
The combination produces a complete SaaS product that's deployable from day one, with minimal operational overhead and a single team to maintain it.