Skip to main content
Back to Blog
Micro-SaaSVertical SaaSEmbedded FintechNext.jsNode.jsExpress.jsPostgreSQLZero TrustStripeCashfreen8nOllamaDockerAWSCI/CDTypeScriptShadcn UI

Architecting a Super-Vertical Micro-SaaS in 2026: Embedded Fintech with Next.js, Node.js, and Zero-Trust PostgreSQL

A practical 2026 blueprint for building a super-vertical micro-SaaS with embedded usage-based payments (Stripe + Cashfree), a Zero-Trust PostgreSQL backend with Row-Level Security, Node.js/Express APIs, n8n workflows, local LLM enrichment via Ollama, and a Next.js + Shadcn UI dashboard deployed on AWS with Docker and CI/CD.

October 30, 202626 min readNiraj Kumar

Introduction: The Quiet Gold Rush of 2026

A couple of years ago, the dominant advice for indie founders and small engineering teams was "build an AI wrapper and grow on social." By 2026, most of us have watched that playbook collapse. Distribution got expensive, platform algorithms reshuffled overnight, and horizontal AI tools turned into features inside products people already pay for.

What's working instead is less glamorous and far more durable: super-vertical software. Think of a scheduling, invoicing, compliance, and payments platform built only for independent physiotherapy clinics. Or a job-costing and payout system built only for solar installation subcontractors. These products don't need to go viral. They need to be the one tool a narrow industry can't run without.

Two technical shifts make this the right moment:

  1. Embedded fintech is now table stakes. Customers expect to collect payments, pay out partners, and see usage-based invoices inside the vertical tool itself. Every payment that flows through your platform is revenue you capture and a switching cost you build.
  2. Zero-Trust data architecture is a sales requirement. Small businesses in healthcare, legal, finance, and trades are increasingly asked by insurers and regulators about data handling. "We isolate every tenant at the database level and never send your data to third-party AI APIs" closes deals.

In this deep-dive, I'll walk through the architecture I'd ship today for a super-vertical micro-SaaS: a Node.js and Express REST API on Zero-Trust PostgreSQL, embedded usage-based billing via Stripe and Cashfree, workflow automation with self-hosted n8n and Zapier, private data enrichment via Ollama, and a fast Next.js + TypeScript + Shadcn UI dashboard, all containerized and deployed on AWS through a proper CI/CD pipeline.

I'll use a running example throughout: ClinicLedger, a fictional platform for independent veterinary clinics that handles appointments, treatment-based billing, pet-owner payments (cards in the US/EU, UPI in India), and automated insurance claim prep.

Super-vertical micro-SaaS architecture overview showing client and clinic reception dashboards, an Express API, Zero-Trust PostgreSQL with RLS, payments, n8n, and a local LLM in a private VPC

What "Super-Vertical" Actually Means (and Why It Changes Your Architecture)

Traditional vertical SaaS picked an industry and built a decent CRM for it. Super-vertical SaaS goes further: it owns the entire operational loop of a micro-niche.

LayerHorizontal SaaSClassic Vertical SaaSSuper-Vertical SaaS (2026)
Data modelGeneric contacts/dealsIndustry entitiesIndustry entities + regulatory metadata
PaymentsLink out to StripeBasic subscriptionEmbedded collections, payouts, usage billing
AutomationGeneric Zapier recipesA few built-in triggersIndustry-specific workflow engine
AICalls a public LLM APIRarely usedPrivate, local model tuned to domain tasks
Trust modelPerimeter securityApp-level tenant checksZero-Trust, DB-enforced isolation

That table has real engineering consequences:

  • Your schema is your moat. A veterinary clinic's "treatment plan" isn't a generic line item. It has species, dosage, controlled-substance flags, and insurance codes. Model it properly.
  • Money is a first-class domain. You aren't "integrating payments." You're running a small ledger.
  • Trust is enforced at the lowest layer possible. If a bug in your Express middleware forgets a WHERE tenant_id = ..., the database should still refuse to leak data.

The High-Level Architecture

Here's the system at a glance:

flowchart LR
    subgraph Client
        A[Next.js Dashboard<br/>TypeScript + Shadcn UI]
    end

    subgraph AWS_VPC[AWS VPC]
        B[ALB + WAF]
        C[Express.js REST API<br/>Node.js 22 LTS]
        D[(PostgreSQL 17<br/>RLS + pgaudit)]
        E[Outbox Worker]
        F[n8n Self-Hosted]
        G[Ollama<br/>Local LLM on GPU]
        H[(Redis / Queue)]
    end

    subgraph External
        I[Stripe]
        J[Cashfree]
        K[Zapier]
    end

    A -->|HTTPS + JWT| B --> C
    C -->|tenant-scoped session| D
    C --> H
    H --> E
    E --> I
    E --> J
    I -->|signed webhooks| C
    J -->|signed webhooks| C
    E --> F
    F --> G
    F --> K
    G -->|enriched data| C

A few principles drive this layout:

  • The API is the only thing that talks to PostgreSQL with application credentials, and those credentials can't bypass Row-Level Security.
  • Payments are asynchronous. The API writes intent and usage events to the database, and an outbox worker syncs them to gateways. Webhooks close the loop.
  • AI and automation live inside the VPC. n8n and Ollama never require customer data to leave your network.

Building the Zero-Trust PostgreSQL Foundation

Zero-Trust means "never trust, always verify," and in a database it translates to three ideas: least privilege, explicit tenant context on every request, and full auditability.

Step 1: Separate Roles Properly

The most common mistake I see is an Express app connecting as the table owner or a superuser. Row-Level Security policies do not apply to table owners by default, so you've silently disabled your safety net.

sql
-- Owner role: used only by migrations, never by the running app
CREATE ROLE clinic_owner NOLOGIN;
 
-- Application role: what Express connects as
CREATE ROLE clinic_app LOGIN PASSWORD 'rotated-via-secrets-manager'
  NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
 
-- Read-only analytics role
CREATE ROLE clinic_readonly LOGIN PASSWORD 'rotated-via-secrets-manager'
  NOSUPERUSER NOBYPASSRLS;
 
CREATE SCHEMA app AUTHORIZATION clinic_owner;
GRANT USAGE ON SCHEMA app TO clinic_app, clinic_readonly;

Step 2: Tenant-Scoped Tables with RLS

sql
CREATE TABLE app.tenants (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name        text NOT NULL,
  region      text NOT NULL CHECK (region IN ('US', 'EU', 'IN')),
  created_at  timestamptz NOT NULL DEFAULT now()
);
 
CREATE TABLE app.treatments (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id     uuid NOT NULL REFERENCES app.tenants(id),
  patient_id    uuid NOT NULL,
  species       text NOT NULL,
  procedure_code text NOT NULL,
  amount_minor  bigint NOT NULL CHECK (amount_minor >= 0),
  currency      char(3) NOT NULL,
  controlled_substance boolean NOT NULL DEFAULT false,
  created_at    timestamptz NOT NULL DEFAULT now()
);
 
ALTER TABLE app.treatments OWNER TO clinic_owner;
ALTER TABLE app.treatments ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.treatments FORCE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON app.treatments
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
 
GRANT SELECT, INSERT, UPDATE ON app.treatments TO clinic_app;
GRANT SELECT ON app.treatments TO clinic_readonly;
 
CREATE INDEX idx_treatments_tenant_created
  ON app.treatments (tenant_id, created_at DESC);

Notice FORCE ROW LEVEL SECURITY. It makes policies apply even to the owner, which is a nice belt-and-braces measure. And current_setting('app.tenant_id', true) returns NULL when the setting is missing, so a request with no tenant context sees zero rows rather than all of them.

Step 3: Set Tenant Context per Transaction in Node.js

The tenant ID must be set inside the same transaction as the query, using SET LOCAL semantics, so it can never leak across pooled connections.

typescript
// src/db/withTenant.ts
import { Pool, PoolClient } from "pg";
 
export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl: { rejectUnauthorized: true },
  max: 20,
  idleTimeoutMillis: 30_000,
  statement_timeout: 10_000,
});
 
export async function withTenant<T>(
  tenantId: string,
  fn: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // set_config with is_local = true behaves like SET LOCAL
    await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
    const result = await fn(client);
    await client.query("COMMIT");
    return result;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

Step 4: Audit Everything That Matters

Enable pgaudit for writes and role changes, and keep a domain-level audit table for business events (refunds, price changes, controlled-substance entries). Regulators and insurers will ask for "who changed what, and when."

sql
-- postgresql.conf (or RDS parameter group)
-- shared_preload_libraries = 'pgaudit'
-- pgaudit.log = 'write, role, ddl'

💡 Note: On AWS, use RDS or Aurora PostgreSQL with IAM database authentication or Secrets Manager rotation. Short-lived credentials are one of the cheapest Zero-Trust wins you can get.

The Node.js + Express.js REST API Layer

Express is still a pragmatic choice in 2026: huge ecosystem, Express 5 with proper async error handling, and every engineer you'll hire already knows it. The trick is to keep it disciplined.

Project Structure

bash
api/
├── src/
│   ├── app.ts
│   ├── server.ts
│   ├── middleware/
│   │   ├── auth.ts
│   │   ├── rateLimit.ts
│   │   └── errorHandler.ts
│   ├── modules/
│   │   ├── treatments/
│   │   ├── billing/
│   │   └── webhooks/
│   ├── db/
│   │   └── withTenant.ts
│   └── lib/
│       ├── payments/
│       └── validation.ts
├── migrations/
├── Dockerfile
└── package.json

Authentication Middleware That Verifies Every Request

Zero-Trust at the API layer means the tenant is derived from a verified token, never from a header or body the client controls.

typescript
// src/middleware/auth.ts
import { Request, Response, NextFunction } from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";
 
const JWKS = createRemoteJWKSet(new URL(process.env.AUTH_JWKS_URL!));
 
export interface AuthedRequest extends Request {
  auth: { userId: string; tenantId: string; roles: string[] };
}
 
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
  const header = req.headers.authorization;
  if (!header?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "missing_token" });
  }
 
  try {
    const { payload } = await jwtVerify(header.slice(7), JWKS, {
      issuer: process.env.AUTH_ISSUER,
      audience: "clinicledger-api",
    });
 
    (req as AuthedRequest).auth = {
      userId: String(payload.sub),
      tenantId: String(payload["tenant_id"]),
      roles: (payload["roles"] as string[]) ?? [],
    };
    next();
  } catch {
    res.status(401).json({ error: "invalid_token" });
  }
}

A Validated, Tenant-Scoped Endpoint

typescript
// src/modules/treatments/routes.ts
import { Router } from "express";
import { z } from "zod";
import { withTenant } from "../../db/withTenant";
import { AuthedRequest } from "../../middleware/auth";
import { recordUsage } from "../billing/usage";
 
const router = Router();
 
const CreateTreatment = z.object({
  patientId: z.string().uuid(),
  species: z.enum(["canine", "feline", "equine", "avian", "exotic"]),
  procedureCode: z.string().min(2).max(32),
  amountMinor: z.number().int().nonnegative(),
  currency: z.enum(["USD", "EUR", "INR"]),
  controlledSubstance: z.boolean().default(false),
});
 
router.post("/treatments", async (req, res) => {
  const { tenantId, userId } = (req as AuthedRequest).auth;
  const input = CreateTreatment.parse(req.body);
 
  const treatment = await withTenant(tenantId, async (db) => {
    const { rows } = await db.query(
      `INSERT INTO app.treatments
         (tenant_id, patient_id, species, procedure_code, amount_minor, currency, controlled_substance)
       VALUES ($1, $2, $3, $4, $5, $6, $7)
       RETURNING *`,
      [tenantId, input.patientId, input.species, input.procedureCode,
       input.amountMinor, input.currency, input.controlledSubstance]
    );
 
    // Metered usage is written in the SAME transaction
    await recordUsage(db, {
      tenantId,
      metric: "treatment_recorded",
      quantity: 1,
      idempotencyKey: `treatment:${rows[0].id}`,
      actorId: userId,
    });
 
    return rows[0];
  });
 
  res.status(201).json(treatment);
});
 
export default router;

Even though the query passes tenant_id explicitly, RLS's WITH CHECK clause guarantees that a mismatched tenant would be rejected by the database. Defense in depth.

Embedding Usage-Based Fintech with Stripe and Cashfree

This is where super-vertical SaaS turns into a real business. ClinicLedger makes money three ways:

  • A base platform fee per clinic per month
  • Metered usage (per treatment recorded, per AI-generated insurance claim)
  • A small take rate on pet-owner payments collected through the platform

US and EU clinics mostly pay with cards and SEPA, so Stripe is the natural fit. Indian clinics want UPI, RuPay, and net banking with high local success rates, where Cashfree shines. Rather than scattering if (region === 'IN') checks everywhere, build a provider-agnostic payment layer.

The Ledger-First Billing Flow

sequenceDiagram
    participant UI as Next.js Dashboard
    participant API as Express API
    participant DB as PostgreSQL
    participant W as Outbox Worker
    participant PG as Stripe / Cashfree

    UI->>API: POST /treatments
    API->>DB: INSERT treatment + usage_event + outbox row (1 txn)
    API-->>UI: 201 Created
    W->>DB: Poll outbox (FOR UPDATE SKIP LOCKED)
    W->>PG: Report usage / create order (idempotency key)
    PG-->>W: 200 OK
    W->>DB: Mark outbox row as sent
    PG->>API: Signed webhook (payment.succeeded)
    API->>DB: Update invoice + ledger entry

The key idea: your database is the source of truth for what was used and what is owed. The gateways are execution engines. If Stripe or Cashfree is briefly unavailable, nothing is lost; the outbox simply retries.

Usage Events and the Transactional Outbox

sql
CREATE TABLE app.usage_events (
  id               bigserial PRIMARY KEY,
  tenant_id        uuid NOT NULL REFERENCES app.tenants(id),
  metric           text NOT NULL,
  quantity         integer NOT NULL CHECK (quantity > 0),
  idempotency_key  text NOT NULL,
  occurred_at      timestamptz NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, idempotency_key)
);
 
CREATE TABLE app.outbox (
  id           bigserial PRIMARY KEY,
  tenant_id    uuid NOT NULL,
  topic        text NOT NULL,
  payload      jsonb NOT NULL,
  attempts     integer NOT NULL DEFAULT 0,
  sent_at      timestamptz,
  created_at   timestamptz NOT NULL DEFAULT now()
);
typescript
// src/modules/billing/usage.ts
import { PoolClient } from "pg";
 
export async function recordUsage(
  db: PoolClient,
  e: { tenantId: string; metric: string; quantity: number; idempotencyKey: string; actorId: string }
) {
  const { rowCount } = await db.query(
    `INSERT INTO app.usage_events (tenant_id, metric, quantity, idempotency_key)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT (tenant_id, idempotency_key) DO NOTHING`,
    [e.tenantId, e.metric, e.quantity, e.idempotencyKey]
  );
 
  if (rowCount === 1) {
    await db.query(
      `INSERT INTO app.outbox (tenant_id, topic, payload) VALUES ($1, 'usage.report', $2)`,
      [e.tenantId, JSON.stringify(e)]
    );
  }
}

A Provider-Agnostic Payment Interface

typescript
// src/lib/payments/types.ts
export interface CreateCheckoutInput {
  tenantId: string;
  amountMinor: number;
  currency: "USD" | "EUR" | "INR";
  customer: { id: string; email: string; phone?: string };
  idempotencyKey: string;
}
 
export interface PaymentProvider {
  name: "stripe" | "cashfree";
  createCheckout(input: CreateCheckoutInput): Promise<{ redirectUrl: string; providerRef: string }>;
  reportUsage(tenantId: string, metric: string, quantity: number, key: string): Promise<void>;
  verifyWebhook(rawBody: Buffer, headers: Record<string, string | undefined>): boolean;
}

Stripe Adapter (Meters + Checkout)

typescript
// src/lib/payments/stripe.ts
import Stripe from "stripe";
import { PaymentProvider } from "./types";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export const stripeProvider: PaymentProvider = {
  name: "stripe",
 
  async createCheckout(input) {
    const session = await stripe.checkout.sessions.create(
      {
        mode: "payment",
        customer_email: input.customer.email,
        line_items: [{
          price_data: {
            currency: input.currency.toLowerCase(),
            product_data: { name: "Veterinary services" },
            unit_amount: input.amountMinor,
          },
          quantity: 1,
        }],
        metadata: { tenant_id: input.tenantId },
        success_url: `${process.env.APP_URL}/payments/success`,
        cancel_url: `${process.env.APP_URL}/payments/cancel`,
      },
      { idempotencyKey: input.idempotencyKey }
    );
    return { redirectUrl: session.url!, providerRef: session.id };
  },
 
  async reportUsage(tenantId, metric, quantity, key) {
    const stripeCustomerId = await lookupStripeCustomer(tenantId);
    await stripe.billing.meterEvents.create({
      event_name: metric,
      identifier: key, // deduplicates on Stripe's side too
      payload: { stripe_customer_id: stripeCustomerId, value: String(quantity) },
    });
  },
 
  verifyWebhook(rawBody, headers) {
    try {
      stripe.webhooks.constructEvent(
        rawBody,
        headers["stripe-signature"]!,
        process.env.STRIPE_WEBHOOK_SECRET!
      );
      return true;
    } catch {
      return false;
    }
  },
};
 
declare function lookupStripeCustomer(tenantId: string): Promise<string>;

Cashfree Adapter (Orders + UPI)

typescript
// src/lib/payments/cashfree.ts
import crypto from "node:crypto";
import { PaymentProvider } from "./types";
 
const BASE = process.env.CASHFREE_ENV === "production"
  ? "https://api.cashfree.com/pg"
  : "https://sandbox.cashfree.com/pg";
 
export const cashfreeProvider: PaymentProvider = {
  name: "cashfree",
 
  async createCheckout(input) {
    const res = await fetch(`${BASE}/orders`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-client-id": process.env.CASHFREE_APP_ID!,
        "x-client-secret": process.env.CASHFREE_SECRET_KEY!,
        "x-api-version": process.env.CASHFREE_API_VERSION ?? "2023-08-01",
        "x-idempotency-key": input.idempotencyKey,
      },
      body: JSON.stringify({
        order_amount: input.amountMinor / 100,
        order_currency: input.currency,
        customer_details: {
          customer_id: input.customer.id,
          customer_email: input.customer.email,
          customer_phone: input.customer.phone,
        },
        order_tags: { tenant_id: input.tenantId },
      }),
    });
 
    if (!res.ok) throw new Error(`cashfree_order_failed:${res.status}`);
    const order = await res.json();
    return { redirectUrl: order.payment_session_id, providerRef: order.order_id };
  },
 
  async reportUsage() {
    // Cashfree has no native metering; usage is aggregated in our ledger
    // and invoiced at period end via a scheduled job.
  },
 
  verifyWebhook(rawBody, headers) {
    const ts = headers["x-webhook-timestamp"];
    const sig = headers["x-webhook-signature"];
    if (!ts || !sig) return false;
    const expected = crypto
      .createHmac("sha256", process.env.CASHFREE_SECRET_KEY!)
      .update(ts + rawBody.toString("utf8"))
      .digest("base64");
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
  },
};

⚠️ Warning: Always verify the Cashfree API version header and webhook signature format against the current Cashfree docs for your account. Gateway APIs evolve, and a silent signature mismatch will drop real payments.

Routing by Region

typescript
// src/lib/payments/index.ts
import { stripeProvider } from "./stripe";
import { cashfreeProvider } from "./cashfree";
 
export function providerFor(region: "US" | "EU" | "IN") {
  return region === "IN" ? cashfreeProvider : stripeProvider;
}

Webhooks: Raw Body, Verify, Then Act

A classic bug: express.json() parses the body before your webhook handler runs, so the signature no longer matches. Mount webhook routes with a raw parser before the JSON middleware.

typescript
// src/app.ts (excerpt)
import express from "express";
import helmet from "helmet";
import webhooks from "./modules/webhooks/routes";
import treatments from "./modules/treatments/routes";
import { requireAuth } from "./middleware/auth";
 
const app = express();
app.use(helmet());
 
app.use("/webhooks", express.raw({ type: "application/json" }), webhooks);
 
app.use(express.json({ limit: "256kb" }));
app.use("/v1", requireAuth, treatments);
 
export default app;

Store each processed webhook event ID in a processed_webhooks table with a unique constraint. Gateways retry, and your handler must be idempotent.

Industry-Specific Workflows with n8n and Zapier

Super-vertical products win on workflow, not features. For ClinicLedger, a typical automation is: after a surgical treatment is paid, prepare an insurance claim, notify the pet owner, and schedule a follow-up.

When to Use Which

  • Self-hosted n8n handles anything touching patient or financial data: claim preparation, reconciliation, LLM enrichment, internal alerts. It runs inside your VPC, and you control retention.
  • Zapier is for the long tail of customer-side integrations: "send new appointments to my Google Calendar," "post daily revenue to our Slack." Expose a small set of well-documented webhooks and let customers wire them up.

Securing the n8n Trigger

Never expose n8n webhooks directly to the internet with no auth. The outbox worker calls n8n over the private network with an HMAC signature:

typescript
// src/workers/dispatchToN8n.ts
import crypto from "node:crypto";
 
export async function dispatchToN8n(topic: string, payload: unknown) {
  const body = JSON.stringify({ topic, payload, sentAt: new Date().toISOString() });
  const signature = crypto
    .createHmac("sha256", process.env.N8N_SHARED_SECRET!)
    .update(body)
    .digest("hex");
 
  const res = await fetch(`${process.env.N8N_INTERNAL_URL}/webhook/${topic}`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-Signature": signature },
    body,
    signal: AbortSignal.timeout(5_000),
  });
 
  if (!res.ok) throw new Error(`n8n_dispatch_failed:${res.status}`);
}

Inside n8n, the first node after the webhook should be a Code node that recomputes and compares the HMAC, rejecting anything that doesn't match. Only data the workflow strictly needs should be in the payload: send IDs and fetch details through a scoped internal API call, rather than dumping full records into workflow history.

flowchart TD
    A[treatment.paid event] --> B{Verify HMAC}
    B -- invalid --> X[Reject + alert]
    B -- valid --> C[Fetch claim data via internal API]
    C --> D[Ollama: extract diagnosis codes]
    D --> E{Schema valid?}
    E -- no --> F[Route to human review queue]
    E -- yes --> G[Generate claim PDF]
    G --> H[Notify pet owner]
    H --> I[Schedule follow-up appointment]
    I --> J[Record usage: ai_claim_generated]

Private Data Enrichment with Ollama (Local LLMs)

Here's a pitch that resonates with clinics, law offices, and accountants: "Your data never leaves our infrastructure, not even for AI features." Ollama makes that practical.

Typical enrichment tasks in a vertical product:

  • Extracting structured codes from a vet's free-text treatment notes
  • Classifying incoming documents (invoice, lab result, consent form)
  • Summarizing a patient's history for a referral letter
  • Flagging anomalies like a dosage outside the typical species range

Calling Ollama with Structured Output

typescript
// src/lib/ai/extractClaimCodes.ts
import { z } from "zod";
 
const ClaimExtraction = z.object({
  diagnosis: z.string().max(200),
  procedureCodes: z.array(z.string().regex(/^[A-Z0-9-]{2,16}$/)).max(10),
  urgency: z.enum(["routine", "urgent", "emergency"]),
  confidence: z.number().min(0).max(1),
});
 
export type ClaimExtraction = z.infer<typeof ClaimExtraction>;
 
export async function extractClaimCodes(notes: string): Promise<ClaimExtraction | null> {
  const res = await fetch(`${process.env.OLLAMA_URL}/api/chat`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    signal: AbortSignal.timeout(20_000),
    body: JSON.stringify({
      model: process.env.OLLAMA_MODEL ?? "qwen2.5:14b-instruct",
      stream: false,
      format: {
        type: "object",
        properties: {
          diagnosis: { type: "string" },
          procedureCodes: { type: "array", items: { type: "string" } },
          urgency: { type: "string", enum: ["routine", "urgent", "emergency"] },
          confidence: { type: "number" },
        },
        required: ["diagnosis", "procedureCodes", "urgency", "confidence"],
      },
      options: { temperature: 0 },
      messages: [
        {
          role: "system",
          content:
            "You extract structured insurance data from veterinary notes. " +
            "Only use codes explicitly supported by the notes. If unsure, lower confidence.",
        },
        { role: "user", content: notes.slice(0, 8_000) },
      ],
    }),
  });
 
  if (!res.ok) return null;
  const data = await res.json();
 
  const parsed = ClaimExtraction.safeParse(JSON.parse(data.message.content));
  if (!parsed.success || parsed.data.confidence < 0.7) return null; // human review
  return parsed.data;
}

Three things make this production-worthy: a JSON schema passed to the model, Zod validation on the way out, and a confidence threshold that routes uncertain results to a human. LLMs are assistants in a regulated workflow, not decision-makers.

On AWS, a single g5.xlarge or g6.xlarge instance comfortably serves a quantized 7B–14B model for a few hundred clinics if you queue requests. Keep it in a private subnet with no public IP, reachable only from the n8n and API security groups.

The Next.js + TypeScript + Shadcn UI Dashboard

Vertical users aren't power users who love complex interfaces. They're clinic managers juggling a waiting room. The dashboard has to be fast, obvious, and forgiving.

Clinic dashboard built with Next.js and Shadcn UI showing current-period revenue, metered usage, pending insurance claims, and recent payments

Server Components for Data, Client Components for Interaction

With the Next.js App Router, fetch data in Server Components so tokens never touch the browser bundle, and keep client components small.

tsx
// app/(dashboard)/billing/page.tsx
import { cookies } from "next/headers";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { UsageChart } from "./usage-chart";
 
type UsageSummary = {
  periodStart: string;
  periodEnd: string;
  metrics: { metric: string; quantity: number; amountMinor: number }[];
  currency: string;
};
 
async function getUsage(): Promise<UsageSummary> {
  const token = (await cookies()).get("session")?.value;
  const res = await fetch(`${process.env.API_URL}/v1/billing/usage`, {
    headers: { Authorization: `Bearer ${token}` },
    cache: "no-store",
  });
  if (!res.ok) throw new Error("Failed to load usage");
  return res.json();
}
 
export default async function BillingPage() {
  const usage = await getUsage();
  const total = usage.metrics.reduce((sum, m) => sum + m.amountMinor, 0);
  const fmt = new Intl.NumberFormat(undefined, { style: "currency", currency: usage.currency });
 
  return (
    <div className="grid gap-6 md:grid-cols-3">
      <Card>
        <CardHeader>
          <CardTitle>Current period</CardTitle>
        </CardHeader>
        <CardContent className="text-3xl font-semibold">{fmt.format(total / 100)}</CardContent>
      </Card>
 
      <Card className="md:col-span-2">
        <CardHeader>
          <CardTitle>Usage breakdown</CardTitle>
        </CardHeader>
        <CardContent>
          <UsageChart data={usage.metrics} />
        </CardContent>
      </Card>
    </div>
  );
}

Server Actions for Mutations

tsx
// app/(dashboard)/payments/actions.ts
"use server";
 
import { cookies } from "next/headers";
import { z } from "zod";
 
const Input = z.object({
  patientId: z.string().uuid(),
  amountMinor: z.number().int().positive(),
});
 
export async function createPaymentLink(formData: FormData) {
  const parsed = Input.safeParse({
    patientId: formData.get("patientId"),
    amountMinor: Number(formData.get("amountMinor")),
  });
  if (!parsed.success) return { error: "Invalid input" };
 
  const token = (await cookies()).get("session")?.value;
  const res = await fetch(`${process.env.API_URL}/v1/payments/checkout`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(parsed.data),
  });
 
  if (!res.ok) return { error: "Could not create payment link" };
  return res.json() as Promise<{ redirectUrl: string }>;
}

Shadcn UI is a great fit here because you own the component code. When a clinic asks for larger tap targets on the reception tablet, you edit button.tsx directly instead of fighting a library's theme API.

Containerization, CI/CD, and AWS Deployment

A Lean, Non-Root Dockerfile for the API

dockerfile
# api/Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
 
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && npm prune --omit=dev
 
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/dist ./dist
COPY --from=build --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/package.json ./
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:8080/healthz || exit 1
CMD ["node", "dist/server.js"]

Local Development with Docker Compose

yaml
# docker-compose.yml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: clinicledger
    ports: ["5432:5432"]
    volumes: ["pgdata:/var/lib/postgresql/data"]
 
  api:
    build: ./api
    env_file: ./api/.env.local
    depends_on: [db]
    ports: ["8080:8080"]
 
  n8n:
    image: n8nio/n8n:latest
    environment:
      N8N_ENCRYPTION_KEY: dev-only-change-me
      N8N_SECURE_COOKIE: "false"
    ports: ["5678:5678"]
    volumes: ["n8ndata:/home/node/.n8n"]
 
  ollama:
    image: ollama/ollama:latest
    ports: ["11434:11434"]
    volumes: ["ollama:/root/.ollama"]
 
volumes:
  pgdata:
  n8ndata:
  ollama:

CI/CD with GitHub Actions and OIDC

No long-lived AWS keys in your repo secrets. Use GitHub's OIDC provider to assume a narrowly scoped IAM role.

yaml
# .github/workflows/deploy-api.yml
name: deploy-api
 
on:
  push:
    branches: [main]
    paths: ["api/**"]
 
permissions:
  id-token: write
  contents: read
 
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: test
        ports: ["5432:5432"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
          cache-dependency-path: api/package-lock.json
      - run: npm ci
        working-directory: api
      - run: npm run migrate && npm test
        working-directory: api
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/postgres
 
  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: ap-south-1
      - uses: aws-actions/amazon-ecr-login@v2
        id: ecr
      - name: Build, scan, and push
        run: |
          IMAGE=${{ steps.ecr.outputs.registry }}/clinicledger-api:${{ github.sha }}
          docker build -t $IMAGE ./api
          docker push $IMAGE
          echo "IMAGE=$IMAGE" >> $GITHUB_ENV
      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster clinicledger \
            --service api \
            --force-new-deployment

Include an RLS regression test in your suite: create two tenants, set the context to tenant A, and assert that querying tenant B's rows returns nothing. It's the single most valuable test in a multi-tenant system.

CI/CD pipeline from GitHub Actions through ECR to ECS Fargate, with public, private app, and isolated data subnets for the ALB, API, n8n, GPU Ollama node, and RDS PostgreSQL inside an AWS VPC

AWS Topology in Brief

  • Public subnets: Application Load Balancer with AWS WAF only
  • Private app subnets: ECS Fargate tasks for the API and outbox worker; EC2 GPU instance for Ollama; n8n on ECS or EC2
  • Isolated data subnets: RDS PostgreSQL with encryption at rest, no public access, and TLS enforced
  • Secrets: AWS Secrets Manager with automatic rotation
  • Observability: OpenTelemetry traces to CloudWatch or Grafana, with tenant ID as a span attribute (never PII)

The Next.js dashboard can live on Vercel or on AWS (Amplify or a container behind CloudFront). If you have data-residency commitments, keep it on AWS in the same region as the database.

Real-World Example: ClinicLedger's First 90 Days

Here's what this architecture looks like in practice for a small team:

  • Weeks 1–3: Schema design with three real clinics on calls. Discovered that "treatment" needed species-specific dosage validation and a controlled-substance log. Generic SaaS would never have modeled that.
  • Weeks 4–6: Stripe metered billing for US clinics; Cashfree UPI collections for two pilot clinics in Pune. UPI success rates were the reason the Indian clinics agreed to switch from their spreadsheet-and-QR-code process.
  • Weeks 7–9: n8n claim-prep workflow with Ollama extraction. Roughly four out of five claims passed the confidence threshold; the rest went to a review queue, which clinic staff actually preferred because they trusted it more.
  • Weeks 10–12: Shipped the Zapier integration for calendar sync and launched to a niche veterinary association's mailing list. Zero paid ads.

The revenue model matters more than the feature list: platform fee plus metered usage plus a payment take rate means revenue grows as clinics get busier, not just when you sign new logos.

Best Practices

  • Model the industry, not the software. Spend real time with practitioners before you write migrations. Your schema is the hardest thing to change later.
  • Store money as integers in minor units (bigint), with an explicit ISO currency code. Never floats.
  • Make every external call idempotent. Use idempotency keys with Stripe, Cashfree, and your own outbox.
  • Enforce tenancy in the database. RLS with FORCE, a non-owner app role, and NOBYPASSRLS.
  • Keep AI behind schemas and thresholds. Validate outputs, log prompts and model versions, and keep a human in the loop for regulated decisions.
  • Version your webhooks. Customers will build Zapier flows against them; breaking changes are expensive.
  • Treat observability as a feature. Clinic owners love an "activity log" page; it's your audit table with a nice UI.

Common Mistakes to Avoid

  1. Connecting the app as the table owner or superuser. RLS silently does nothing. Check with SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user;.
  2. Using session-level SET with a connection pool. Tenant context leaks to the next request on the same connection. Always use transaction-local settings.
  3. Parsing webhook bodies as JSON before verifying signatures. Mount raw-body parsers on webhook routes.
  4. Trusting gateway state as your ledger. If your only record of usage is Stripe's dashboard, reconciling disputes and migrating providers becomes painful.
  5. Exposing n8n or Ollama publicly. Both should be private-network only, behind security groups and signed requests.
  6. Building a horizontal feature set too early. "Let's also support dental clinics" in month two dilutes the very depth that makes a super-vertical product defensible.
  7. Hard-coding a single payment provider. Regional payment preferences are a conversion lever; a thin adapter layer costs a day and pays back for years.

🚀 Pro Tips

  • Charge for outcomes, meter on events. Clinics understand "per insurance claim prepared" far better than "per API call." Name your meters in the customer's language.
  • Use FOR UPDATE SKIP LOCKED for your outbox worker. It lets you scale workers horizontally on plain PostgreSQL without introducing Kafka on day one.
  • Run a nightly reconciliation job that compares your usage_events totals with what Stripe and Cashfree report. Catching drift early saves awkward billing emails.
  • Pin your Ollama model by digest, not just by tag, so a silent model update doesn't change extraction behavior in production.
  • Add a "data residency" line to your pricing page. For regulated small businesses, "hosted in your region, AI runs on our own servers" is a feature they will pay for.
  • Seed demo tenants with realistic, synthetic industry data. Sales demos for vertical SaaS live or die on whether the data "looks like my clinic."
  • Partner with the niche's association or software reseller. Distribution in a micro-niche is relationship-driven, and it's immune to algorithm changes.

📌 Key Takeaways

  • Super-vertical beats horizontal in 2026 because depth of workflow creates switching costs that marketing alone can't.
  • Embedded fintech is the revenue engine. Combine a platform fee, metered usage, and a payment take rate, routed through Stripe and Cashfree by region.
  • Zero-Trust starts in PostgreSQL. RLS, least-privilege roles, transaction-scoped tenant context, and audit logging protect you even when application code has bugs.
  • Your ledger is the source of truth. Event-source usage, sync through an outbox, and verify every webhook.
  • Private AI is a selling point. Ollama inside your VPC enables useful enrichment without sending sensitive data to third parties.
  • Automate the industry's real workflow with n8n internally and Zapier for customer-facing integrations.
  • Ship with discipline: non-root containers, OIDC-based CI/CD, private subnets, and an RLS regression test in every build.

Conclusion

The developers who'll build the most durable businesses over the next few years aren't chasing the biggest markets. They're picking a narrow industry, learning it deeply, and building software so well-fitted that it becomes part of how that industry operates, including how it gets paid.

The stack described here isn't exotic. Next.js, Node.js, Express, PostgreSQL, Docker, and AWS are familiar tools. What makes the architecture powerful is how they're combined: tenancy enforced by the database, money handled as a ledger rather than an afterthought, automation tuned to real industry processes, and AI that respects data boundaries. That combination is exactly what small, regulated businesses are looking for in 2026, and exactly what large horizontal platforms struggle to offer.

Pick your niche. Sit with the people who work in it. Then build the system they'd never want to leave.

References

Frequently asked questions

What is a super-vertical micro-SaaS?

It's a small, focused software product built for a single narrow industry segment (for example, independent veterinary clinics or HVAC subcontractors) that covers the whole workflow end to end, including payments, automation, and reporting, instead of solving one horizontal problem for everyone.

Why use both Stripe and Cashfree instead of just one payment gateway?

Stripe is excellent for global card payments and metered subscriptions, while Cashfree offers strong support for Indian payment rails like UPI, local cards, and net banking with better local success rates. A provider-agnostic payment layer lets you route each customer to the gateway that converts best for their region.

Is Row-Level Security in PostgreSQL enough for multi-tenant isolation?

RLS is a strong foundation, but it should be combined with least-privilege database roles, a non-owner application role (so policies can't be bypassed), encrypted connections, audit logging, and secrets rotation. Zero-Trust is a layered approach, not a single feature.

Should I self-host n8n or use Zapier?

Use self-hosted n8n for core, data-sensitive, high-volume workflows that touch customer records. Use Zapier for low-volume, customer-facing integrations where its large app catalog saves you from building and maintaining connectors.

Can a local LLM like Ollama really run in production?

Yes, for focused tasks such as classification, extraction, and summarization. A quantized 7B to 14B model on a GPU instance handles these well. Queue requests, set strict timeouts, validate outputs against a schema, and keep a deterministic fallback for when the model is slow or wrong.

Discussion

All Articles
Micro-SaaSVertical SaaSEmbedded FintechNext.jsNode.jsExpress.jsPostgreSQLZero TrustStripeCashfreen8nOllamaDockerAWSCI/CDTypeScriptShadcn UI

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.

Building this for real? Next.js Developer Services — Next.js 15/16 App Router apps with TypeScript, Server Actions, and Vercel/AWS deploys.