Mistral AI in a B2B SaaS: 5 Concrete TypeScript Use Cases
Integrating a European AI provider into your SaaS: data extraction, summaries, email classification, content generation, and semantic search.
Why Mistral over OpenAI?
Three concrete reasons for a SaaS selling into the EU market:
- Data sovereignty: your customers' data never leaves the EU. A solid GDPR argument in front of an enterprise buyer.
- Price:
mistral-small-latestcosts roughly 10x less than GPT-4o for structured extraction tasks. - EU latency: Mistral's servers are in Paris, not in Virginia. Your webhooks respond in 200ms instead of 600ms.
Minimal setup:
// src/lib/ai/mistral.ts
import { Mistral } from "@mistralai/mistralai";
import { env } from "~/env";
export const mistral = new Mistral({ apiKey: env.MISTRAL_API_KEY });Use case 1: extracting data from a PDF
A customer sends a supplier invoice as a PDF. You want to automatically extract the amount, date, and invoice number.
import { z } from "zod";
const InvoiceSchema = z.object({
invoiceNumber: z.string(),
date: z.string(),
totalHT: z.number(),
totalTTC: z.number(),
vatRate: z.number(),
supplierName: z.string(),
});
export async function extractInvoiceFromText(text: string) {
const response = await mistral.chat.complete({
model: "mistral-small-latest",
responseFormat: { type: "json_object" },
messages: [
{
role: "system",
content:
"You extract structured data from invoices. Respond only with valid JSON.",
},
{
role: "user",
content: `Extract these fields from the invoice: invoiceNumber, date (YYYY-MM-DD), totalHT, totalTTC, vatRate, supplierName.
Invoice:
${text}`,
},
],
});
const raw = response.choices[0]?.message.content;
if (typeof raw !== "string") throw new Error("Empty response");
return InvoiceSchema.parse(JSON.parse(raw));
}Critical tip
Use responseFormat: { type: "json_object" } and validate with Zod.
Otherwise you'll be JSON.parse-ing markdown one day out of two.
Use case 2: summarizing customer history for the CRM
When a sales rep opens a customer record, they want to understand in 5 seconds what's been happening: recent exchanges, buying signals, friction points.
// src/server/api/routers/crm.ts
import { mistral } from "~/lib/ai/mistral";
summarizeClientHistory: requirePermission("crm:read")
.input(z.object({ clientId: z.string() }))
.query(async ({ ctx, input }) => {
const interactions = await ctx.orgDb.clientInteraction.findMany({
where: { clientId: input.clientId },
orderBy: { createdAt: "desc" },
take: 20,
});
if (interactions.length === 0) return null;
const transcript = interactions
.map((i) => `[${i.type}] ${i.createdAt.toISOString()}: ${i.content}`)
.join("\n");
const response = await mistral.chat.complete({
model: "mistral-small-latest",
maxTokens: 200,
messages: [
{
role: "system",
content:
"You are a CRM assistant. Summarize in 3 bullets: current status, latest buying signal, recommended next action.",
},
{ role: "user", content: transcript },
],
});
return response.choices[0]?.message.content ?? null;
}),Caching is mandatory
A single Mistral summary costs roughly $0.01. Multiply that by 200 users opening 50 records a day, and you're at $100/day. Cache the response for 24h in Redis, invalidated on the next interaction.
Use case 3: classifying incoming emails
You receive customer emails in a support inbox. You want to route them automatically to the right agent.
const Category = z.enum([
"BUG_REPORT",
"FEATURE_REQUEST",
"BILLING",
"ONBOARDING",
"CHURN_RISK",
"OTHER",
]);
export async function classifyEmail(subject: string, body: string) {
const response = await mistral.chat.complete({
model: "mistral-small-latest",
responseFormat: { type: "json_object" },
maxTokens: 50,
messages: [
{
role: "system",
content: `You classify support emails. Respond in JSON: {"category": "...", "urgent": boolean}.
Valid categories: BUG_REPORT, FEATURE_REQUEST, BILLING, ONBOARDING, CHURN_RISK, OTHER.`,
},
{ role: "user", content: `Subject: ${subject}\n\n${body}` },
],
});
const result = z
.object({ category: Category, urgent: z.boolean() })
.parse(JSON.parse(response.choices[0]?.message.content ?? "{}"));
return result;
}Typical performance: 92% accuracy after hand-labeling 200 emails for few-shot prompting.
Use case 4: generating content (product descriptions)
A user adds a product to their catalog. They enter the name and 3 key features. You generate the marketing description.
generateProductDescription: requirePermission("catalog:write")
.input(
z.object({
productName: z.string(),
keyFeatures: z.array(z.string()).min(1).max(5),
tone: z.enum(["professional", "casual", "premium"]).default("professional"),
}),
)
.mutation(async ({ ctx, input }) => {
// Freemium guard before the call
const guard = await checkFreemiumLimit(ctx, "aiGenerations");
if (!guard.allowed) {
throw new TRPCError({
code: "FORBIDDEN",
message: `AI quota reached (${guard.used}/${guard.limit})`,
});
}
const response = await mistral.chat.complete({
model: "mistral-small-latest",
maxTokens: 250,
messages: [
{
role: "system",
content: `You write product descriptions, ${input.tone} tone. 80 words max.`,
},
{
role: "user",
content: `Product: ${input.productName}\nFeatures:\n- ${input.keyFeatures.join("\n- ")}`,
},
],
});
// Increment the counter AFTER success
await ctx.db.subscription.update({
where: { organizationId: ctx.session.user.organizationId },
data: { aiGenerationsUsed: { increment: 1 } },
});
return response.choices[0]?.message.content ?? "";
}),Critical pattern
Check-before, increment-after. If the Mistral call fails, the quota isn't decremented.
Use case 5: semantic search with embeddings
A user types "unpaid invoice Smith" and you want to surface results even when the exact words aren't in the database.
// 1. When a resource is created, compute its embedding
export async function indexDocument(id: string, text: string) {
const response = await mistral.embeddings.create({
model: "mistral-embed",
inputs: [text],
});
const embedding = response.data[0]?.embedding;
if (!embedding) throw new Error("Embedding failed");
// Stored via pgvector
await db.$executeRaw`
UPDATE "Document"
SET embedding = ${embedding}::vector
WHERE id = ${id}
`;
}
// 2. At search time
export async function semanticSearch(query: string, orgId: string) {
const queryEmbedding = (
await mistral.embeddings.create({
model: "mistral-embed",
inputs: [query],
})
).data[0]?.embedding;
return db.$queryRaw`
SELECT id, title, 1 - (embedding <=> ${queryEmbedding}::vector) AS similarity
FROM "Document"
WHERE "organizationId" = ${orgId}
ORDER BY embedding <=> ${queryEmbedding}::vector
LIMIT 10
`;
}Cost: mistral-embed runs $0.10 per 1M tokens. Indexing 10,000 documents of 500 words costs about $0.50. The search query itself costs a fraction of a cent.
The overall cost pattern
| Use case | Cost/call | Typical frequency | Monthly cost (1,000 users) |
|---|---|---|---|
| PDF extraction | ~$0.005 | 5/user/month | $25 |
| CRM summary | ~$0.002 | 30/user/month (cached) | $60 |
| Email classification | ~$0.0003 | 50/user/month | $15 |
| Product generation | ~$0.003 | 10/user/month | $30 |
| Embeddings (index+search) | ~$0.0001 | 200/user/month | $20 |
| Total | ~$150/month |
You're charging $30-150/month per plan. The margin on AI is comfortable.
Mistakes to avoid
- Calling Mistral from a Server Component with no cache: a page refresh becomes an API call. Always wrap it with
unstable_cacheor Redis. - No retry on network errors: Mistral has a 99.9% SLA but real spikes happen. Implement 3 retries with exponential backoff.
- Streaming from an Edge Function over 25s: Vercel cuts off at 30s. For long streams, use a regular Node.js endpoint instead.
- Forgetting the freemium guard before the call: without it, a free-plan user can burn $1,000 of AI usage in one night.
Conclusion
Mistral isn't "OpenAI but French." It's a stack suited to European SaaS: GDPR-friendly, cheap, and good enough for 90% of B2B use cases. For the remaining 10% (complex reasoning, code generation), keep a fallback to Claude or GPT-4o, but start with Mistral.
In HeartCo, the Mistral integration is pre-wired: freemium guard, retry, Redis cache, and examples in src/lib/ai/. You add your own use case in about 30 lines.
Go further
Related articles
Steering Claude Code with CLAUDE.md on a Multi-Tenant SaaS
The real rules that govern this SaaS with Claude Code: what goes in CLAUDE.md, what doesn't, and what's actually shipped at purchase.
ReadLaunching a SaaS Without Coding in 2026: The Hybrid No-Code + Code Stack
How to go from idea to a production SaaS without writing a line of Stripe or NextAuth code, using a builder that generates the code for you.
ReadElectronic Invoicing 2026 for a B2B SaaS in France
Official timeline, PDP and e-reporting vocabulary, generating a Factur-X, and the role of a connector like iopole for a French B2B SaaS.
ReadReady to launch your SaaS?
HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.