Explore
Building a SaaS Product With Nuxt: The Architecture That Works

Building a SaaS Product With Nuxt: The Architecture That Works

Nuxt can serve as the foundation for a complete SaaS product — not just a frontend. Here's the architecture we've used across multiple products, and why it works.

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

Email

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.

Want to build a SaaS product on this stack? Let's talk →