HunterAlphaHub
OpenRouter model reference Facts from the public catalogue, dated and labelled

Tutorial

Build a Document Analysis SaaS with Xiaomi mimo-v2

Step-by-step guide to building a document analysis SaaS using Xiaomi mimo-v2 (Hunter Alpha): architecture, code examples, pricing, and launch strategy.

Hunter Alpha Hub Team 23 March 2026 11 min read

Build a Document Analysis SaaS with Xiaomi mimo-v2

Overview

This guide walks you through building a SaaS product that analyzes long documents using Xiaomi mimo-v2’s 1M token context window.

What we’ll build:

  • Upload documents (PDF, TXT, DOCX)
  • Get AI-powered analysis: summaries, insights, entity extraction
  • Export reports as PDF/Markdown
  • Subscription billing

Tech stack:

  • Frontend: Next.js 15
  • Backend: Node.js/Express
  • AI: Xiaomi mimo-v2 via OpenRouter
  • Database: PostgreSQL
  • Storage: AWS S3
  • Payments: Stripe

Architecture

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│   User      │────▶│  Next.js App │────▶│   OpenRouter│
│  Uploads    │     │   /analyze   │     │  mimo-v2 API│
└─────────────┘     └──────────────┘     └─────────────┘
                          │
                          ▼
                   ┌──────────────┐
                   │  PostgreSQL  │
                   │  (jobs, users)│
                   └──────────────┘
                          │
                          ▼
                   ┌──────────────┐
                   │   AWS S3     │
                   │  (documents) │
                   └──────────────┘

Step 1: Project Setup

# Create Next.js app
npx create-next-app@latest doc-analyzer --typescript --tailwind --app
cd doc-analyzer

# Install dependencies
npm install @openrouter/ai-sdk-provider ai stripe @prisma/client aws-sdk
npm install -D prisma

# Initialize Prisma
npx prisma init

Step 2: Database Schema

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  plan      String   @default("free") // free, pro, enterprise
  credits   Int      @default(5)
  documents Document[]
  createdAt DateTime @default(now())
}

model Document {
  id          String   @id @default(cuid())
  userId      String
  user        User     @relation(fields: [userId], references: [id])
  filename    String
  s3Key       String
  status      String   @default("pending") // pending, processing, completed, failed
  analysis    Json?
  tokenCount  Int?
  createdAt   DateTime @default(now())
}

Step 3: File Upload API

// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getServerSession } from 'next-auth';
import { prisma } from '@/lib/prisma';

const s3 = 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 POST(request: NextRequest) {
  const session = await getServerSession();
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const formData = await request.formData();
  const file = formData.get('file') as File;

  if (!file) {
    return NextResponse.json({ error: 'No file provided' }, { status: 400 });
  }

  // Get user
  const user = await prisma.user.findUnique({
    where: { email: session.user.email! },
  });

  if (!user || user.credits <= 0) {
    return NextResponse.json({ error: 'Insufficient credits' }, { status: 403 });
  }

  // Upload to S3
  const s3Key = `documents/${user.id}/${Date.now()}-${file.name}`;
  const buffer = Buffer.from(await file.arrayBuffer());

  await s3.send(new PutObjectCommand({
    Bucket: process.env.S3_BUCKET!,
    Key: s3Key,
    Body: buffer,
    ContentType: file.type,
  }));

  // Create document record
  const doc = await prisma.document.create({
    data: {
      userId: user.id,
      filename: file.name,
      s3Key,
      status: 'pending',
    },
  });

  // Deduct credit
  await prisma.user.update({
    where: { id: user.id },
    data: { credits: user.credits - 1 },
  });

  return NextResponse.json({ documentId: doc.id });
}

Step 4: Analysis Worker

// lib/analyze.ts
import { prisma } from './prisma';
import fs from 'fs';
import { createReadStream } from 'fs';
import pdfParse from 'pdf-parse';

export async function analyzeDocument(documentId: string) {
  // Update status
  await prisma.document.update({
    where: { id: documentId },
    data: { status: 'processing' },
  });

  try {
    // Get document
    const doc = await prisma.document.findUnique({
      where: { id: documentId },
      include: { user: true },
    });

    if (!doc) throw new Error('Document not found');

    // Download from S3
    const s3 = new S3Client({ /* ... */ });
    const { Body } = await s3.send(new GetObjectCommand({
      Bucket: process.env.S3_BUCKET!,
      Key: doc.s3Key,
    }));

    // Extract text (simplified - handle PDF, DOCX)
    let text = '';
    if (doc.filename.endsWith('.pdf')) {
      const pdfBuffer = await streamToBuffer(Body as NodeJS.ReadableStream);
      const pdfData = await pdfParse(pdfBuffer);
      text = pdfData.text;
    } else {
      text = await streamToText(Body as NodeJS.ReadableStream);
    }

    // Estimate tokens
    const tokenCount = Math.ceil(text.length / 4);

    // Call mimo-v2
    const analysis = await callHunterAlpha(text);

    // Save results
    await prisma.document.update({
      where: { id: documentId },
      data: {
        status: 'completed',
        analysis,
        tokenCount,
      },
    });

  } catch (error) {
    console.error('Analysis failed:', error);
    await prisma.document.update({
      where: { id: documentId },
      data: { status: 'failed' },
    });
  }
}

async function callHunterAlpha(documentText: string) {
  const prompt = `
Analyze the following document and provide:

1. **Executive Summary** (3-5 sentences)
2. **Key Points** (5-10 bullet points)
3. **Entities Extracted** (people, organizations, locations)
4. **Sentiment Analysis** (overall tone)
5. **Action Items** (any tasks or recommendations mentioned)
6. **Questions Raised** (unresolved issues or ambiguities)

Document:
${documentText}
  `;

  const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'xiaomi/mimo-v2',
      messages: [{ role: 'user', content: prompt }],
      max_tokens: 4096,
    }),
  });

  const data = await response.json();
  return data.choices[0].message.content;
}

Step 5: Frontend Upload Component

// components/document-uploader.tsx
'use client';

import { useState } from 'react';

export function DocumentUploader() {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);

  async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0];
    if (!file) return;

    setUploading(true);
    setProgress(0);

    const formData = new FormData();
    formData.append('file', file);

    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData,
    });

    if (!response.ok) {
      const error = await response.json();
      alert('Upload failed: ' + error.error);
      setUploading(false);
      return;
    }

    const data = await response.json();

    // Redirect to analysis page
    window.location.href = `/documents/${data.documentId}`;
  }

  return (
    <div className="border-2 border-dashed border-gray-700 rounded-lg p-8 text-center">
      <input
        type="file"
        onChange={handleUpload}
        accept=".pdf,.txt,.docx"
        disabled={uploading}
        className="hidden"
        id="file-upload"
      />
      <label
        htmlFor="file-upload"
        className="cursor-pointer text-violet-400 hover:text-violet-300"
      >
        {uploading ? 'Uploading...' : 'Click to upload or drag and drop'}
      </label>
      <p className="text-sm text-gray-500 mt-2">
        PDF, TXT, or DOCX up to 50MB
      </p>
    </div>
  );
}

Step 6: Results Page

// app/documents/[id]/page.tsx
export default function DocumentPage({ params }: { params: { id: string } }) {
  const [document, setDocument] = useState(null);

  useEffect(() => {
    async function fetchDocument() {
      const res = await fetch(`/api/documents/${params.id}`);
      const data = await res.json();
      setDocument(data);
    }
    fetchDocument();

    // Poll for status updates
    const interval = setInterval(fetchDocument, 3000);
    return () => clearInterval(interval);
  }, [params.id]);

  if (!document) return <div>Loading...</div>;

  if (document.status === 'pending' || document.status === 'processing') {
    return (
      <div className="text-center py-12">
        <div className="animate-spin w-8 h-8 border-4 border-violet-500 rounded-full mx-auto" />
        <p className="mt-4">Analyzing your document...</p>
      </div>
    );
  }

  if (document.status === 'failed') {
    return <div>Analysis failed. Please try again.</div>;
  }

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      <h1 className="text-2xl font-bold mb-4">{document.filename}</h1>

      <div className="prose prose-invert max-w-none">
        <ReactMarkdown>{document.analysis}</ReactMarkdown>
      </div>

      <div className="mt-8 flex gap-4">
        <button
          onClick={() => window.print()}
          className="px-4 py-2 bg-violet-600 rounded hover:bg-violet-700"
        >
          Export as PDF
        </button>
        <button
          onClick={() => navigator.clipboard.writeText(document.analysis)}
          className="px-4 py-2 bg-gray-700 rounded hover:bg-gray-600"
        >
          Copy to Clipboard
        </button>
      </div>
    </div>
  );
}

Step 7: Pricing Model

Free Tier

  • 5 documents/month
  • Up to 100K tokens per document
  • Standard analysis template

Pro ($29/month)

  • 50 documents/month
  • Up to 500K tokens per document
  • Custom analysis templates
  • Priority processing

Enterprise ($199/month)

  • Unlimited documents
  • Full 1M token context
  • API access
  • Custom integrations

Cost Analysis

OpenRouter costs:

  • mimo-v2: Free (as of March 2026)

Your costs:

  • S3 storage: ~$0.023/GB
  • Database: ~$25/month (Neon/Supabase)
  • Vercel hosting: Free-$20/month
  • Stripe fees: 2.9% + $0.30

Margins:

  • Pro plan at $29/month with ~$5 infrastructure cost = 83% margin

Launch Checklist

  • Complete MVP (upload, analyze, export)
  • Add user authentication
  • Integrate Stripe billing
  • Set up rate limiting
  • Create landing page
  • Write documentation
  • Launch on Product Hunt
  • Collect user feedback

Building something similar? What each anonymous release turned out to be is in the stealth models register.

mimo-v2SaaSBusinessDeveloperTutorial

Keep reading

Send us a link

A project built with Jev, a post, a video, a correction, a tip. We open the link, check it says what you said it says, and write the entry ourselves.

Required a link, and an email to reply to. Optional everything else.

Add context — all optional

One or two sentences about what it does, in your words. We write the entry ourselves.

Cost, latency, a benchmark — anything you measured. We attribute these to you.

We store what you type and email it to ourselves. No IP address, no user agent, no referrer — the same rule as the mailing list.

What happens to what you send →