Back to blog
AILLMSaaS architectureRAGprompt engineeringcost managementTypeScript

Building AI-Powered Features into Your SaaS: From LLMs to Production

By SaaS Masters26 maart 202612 min read

AI is no longer a buzzword — it's a core component of modern SaaS products. From intelligent search results to automated content generation, your customers expect AI functionality. But how do you build this responsibly, scalably, and cost-efficiently into your product?

In this article, we'll walk you through the process step by step: from choosing the right model to production-ready implementation with caching, fallbacks, and cost management.

Why AI Features in Your SaaS?

The market is shifting rapidly. SaaS products that integrate AI see:

  • 40-60% higher engagement through intelligent suggestions
  • Lower support costs via AI-powered self-service
  • Stronger retention — AI features quickly become indispensable
  • Premium pricing opportunities for AI tiers

But beware: poorly implemented AI hurts your product more than it helps. Hallucinations, slow responses, and unpredictable costs are real risks.

The Architecture: Where Does AI Fit in Your Stack?

Option 1: API-First (Recommended for Most SaaS)

[Frontend] → [Your API] → [AI Service Layer] → [OpenAI / Anthropic / etc.]
                                ↓
                          [Cache Layer]
                                ↓
                          [Vector DB]

This is the most flexible approach. Your AI layer sits behind your own API, giving you full control over:

  • Rate limiting per tenant
  • Caching of frequent queries
  • Model switching without frontend changes
  • Cost allocation per customer

Option 2: Edge/Client-Side (for Real-Time Features)

For features like autocomplete or real-time suggestions, you can run smaller models closer to the user. Think WebLLM or ONNX models in the browser.

Choosing the Right Model

Not every feature needs GPT-4. Here's a practical framework:

Use CaseRecommended ModelCost/1M Tokens
Classification & taggingGPT-4o-mini / Claude Haiku~$0.25
Summarization & extractionGPT-4o / Claude Sonnet~$3-5
Complex analysis & reasoningGPT-4o / Claude Opus~$15-75
Embeddings & searchtext-embedding-3-small~$0.02
Simple chatGPT-4o-mini / Claude Haiku~$0.25

Pro tip: Always start with the smallest model that works. Only upgrade when quality is insufficient for your use case.

Implementation: Building an AI Service Layer

Here's a production-ready setup in Node.js/TypeScript:

// lib/ai/ai-service.ts
import Anthropic from "@anthropic-ai/sdk";
import { Redis } from "ioredis";
import { createHash } from "crypto";

interface AIRequestOptions {
  prompt: string;
  systemPrompt?: string;
  model?: string;
  maxTokens?: number;
  tenantId: string;
  cacheTtl?: number; // seconds
}

export class AIService {
  private anthropic: Anthropic;
  private redis: Redis;

  constructor() {
    this.anthropic = new Anthropic();
    this.redis = new Redis(process.env.REDIS_URL!);
  }

  async complete(options: AIRequestOptions): Promise<string> {
    const {
      prompt,
      systemPrompt,
      model = "claude-sonnet-4-20250514",
      maxTokens = 1024,
      tenantId,
      cacheTtl = 3600,
    } = options;

    // 1. Check rate limit
    await this.checkRateLimit(tenantId);

    // 2. Check cache
    const cacheKey = this.getCacheKey(prompt, systemPrompt, model);
    const cached = await this.redis.get(cacheKey);
    if (cached) return cached;

    // 3. Call AI provider
    const response = await this.anthropic.messages.create({
      model,
      max_tokens: maxTokens,
      system: systemPrompt || "You are a helpful assistant.",
      messages: [{ role: "user", content: prompt }],
    });

    const result =
      response.content[0].type === "text" ? response.content[0].text : "";

    // 4. Cache result
    await this.redis.setex(cacheKey, cacheTtl, result);

    // 5. Track usage
    await this.trackUsage(tenantId, response.usage);

    return result;
  }

  private async checkRateLimit(tenantId: string): Promise<void> {
    const key = \`rate:${tenantId}:${Math.floor(Date.now() / 60000)}\`;
    const count = await this.redis.incr(key);
    if (count === 1) await this.redis.expire(key, 120);
    if (count > 100) {
      throw new Error("AI rate limit exceeded");
    }
  }

  private getCacheKey(...parts: (string | undefined)[]): string {
    const hash = createHash("sha256")
      .update(parts.filter(Boolean).join("|"))
      .digest("hex");
    return \`ai:cache:${hash}\`;
  }

  private async trackUsage(
    tenantId: string,
    usage: { input_tokens: number; output_tokens: number }
  ): Promise<void> {
    const month = new Date().toISOString().slice(0, 7);
    const key = \`usage:${tenantId}:${month}\`;
    await this.redis.hincrby(key, "input_tokens", usage.input_tokens);
    await this.redis.hincrby(key, "output_tokens", usage.output_tokens);
  }
}

What This Service Does:

  1. Rate limiting per tenant — prevents abuse and unexpected costs
  2. Response caching — identical queries don't hit the AI provider again
  3. Usage tracking — essential for cost allocation and usage-based billing
  4. Model abstraction — switch providers without code changes

Prompt Engineering: The Key to Reliable Output

Bad prompts = bad results. Here are proven patterns:

Structured Output with JSON

const systemPrompt = \`You are a product categorization engine.
Analyze the product description and return ONLY valid JSON:

{
  "category": "string (one of: software, hardware, service, consultancy)",
  "confidence": "number (0-1)",
  "tags": ["string array with relevant tags"],
  "summary": "string (max 100 words)"
}

No extra text outside the JSON object.\`;

Few-Shot Examples

const prompt = \`Classify the following support tickets:

Example 1:
Input: "I can't log in since the update"
Output: { "category": "authentication", "priority": "high", "sentiment": "frustrated" }

Example 2:
Input: "Is there an API for bulk imports?"
Output: { "category": "feature_request", "priority": "medium", "sentiment": "neutral" }

Now your turn:
Input: "${ticketText}"
Output:\`;

Building Guardrails

function validateAIResponse<T>(
  response: string,
  schema: z.ZodSchema<T>
): T | null {
  try {
    // Strip any markdown code blocks
    const cleaned = response
      .replace(/\`\`\`json?\n?/g, "")
      .replace(/\`\`\`/g, "")
      .trim();
    const parsed = JSON.parse(cleaned);
    return schema.parse(parsed);
  } catch {
    return null; // Fallback to manual processing
  }
}

RAG: Making Your AI Smarter with Your Own Data

Retrieval-Augmented Generation (RAG) is the go-to approach for building AI features that use your own product data — without fine-tuning the model.

Step 1: Generate Embeddings

import OpenAI from "openai";

const openai = new OpenAI();

async function generateEmbedding(text: string): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: text,
  });
  return response.data[0].embedding;
}

Step 2: Store in a Vector Database

With pgvector (PostgreSQL extension), you don't need a separate database:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id SERIAL PRIMARY KEY,
  tenant_id TEXT NOT NULL,
  content TEXT NOT NULL,
  embedding vector(1536),
  metadata JSONB DEFAULT '{}'
);

CREATE INDEX ON documents
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

Step 3: Search and Provide Context

async function ragQuery(tenantId: string, question: string): Promise<string> {
  const questionEmbedding = await generateEmbedding(question);

  // Find relevant documents
  const docs = await db.query(
    \`SELECT content, 1 - (embedding <=> $1::vector) as similarity
     FROM documents
     WHERE tenant_id = $2
     ORDER BY embedding <=> $1::vector
     LIMIT 5\`,
    [\`[${questionEmbedding.join(",")}]\`, tenantId]
  );

  // Build context
  const context = docs.rows.map((d) => d.content).join("\n\n---\n\n");

  return aiService.complete({
    tenantId,
    systemPrompt: \`Answer the question based on the following context.
If the answer is not in the context, say so honestly.

Context:
${context}\`,
    prompt: question,
  });
}

Cost Management: The Hidden Challenge

AI costs can explode if you're not careful. Here are concrete strategies:

1. Tiered Model Routing

function selectModel(complexity: "low" | "medium" | "high"): string {
  switch (complexity) {
    case "low":
      return "claude-haiku-4-20250414"; // $0.25/1M
    case "medium":
      return "claude-sonnet-4-20250514"; // $3/1M
    case "high":
      return "claude-opus-4-20250514"; // $15/1M
  }
}

// Automatic complexity detection
function estimateComplexity(prompt: string): "low" | "medium" | "high" {
  const wordCount = prompt.split(/\s+/).length;
  if (wordCount < 50) return "low";
  if (wordCount < 200) return "medium";
  return "high";
}

2. Token Budgets Per Tenant

async function checkBudget(tenantId: string): Promise<boolean> {
  const month = new Date().toISOString().slice(0, 7);
  const usage = await redis.hgetall(\`usage:${tenantId}:${month}\`);
  const totalTokens =
    parseInt(usage.input_tokens || "0") +
    parseInt(usage.output_tokens || "0");

  const plan = await getTenantPlan(tenantId);
  return totalTokens < plan.monthlyTokenLimit;
}

3. Aggressive Caching

  • Cache identical queries (as shown above)
  • Cache embeddings — don't recompute on every request
  • Use semantic caching: if a new query is >95% similar to a cached one, use the cached response

Production Checklist

Before shipping AI features, run through this checklist:

Reliability

  • Fallback when AI provider is down
  • Retry logic with exponential backoff
  • Circuit breaker for provider outages
  • Timeout configuration (AI calls can be slow)

Security

  • Input sanitization (prevent prompt injection)
  • Output validation (check for unwanted content)
  • PII filtering before sending to external APIs
  • Audit logging of all AI interactions

Costs

  • Rate limiting per tenant
  • Token budget alerts
  • Automatic model downgrade under heavy usage
  • Monthly cost reporting per feature

UX

  • Streaming responses for long outputs
  • Clear loading states
  • "AI-generated" labels where needed
  • Feedback mechanism (thumbs up/down)

Implementing Streaming Responses

Nothing is more frustrating than staring at a spinner for 10 seconds. Streaming makes AI features feel responsive:

// Next.js API Route with streaming
import { Anthropic } from "@anthropic-ai/sdk";

export async function POST(req: Request) {
  const { prompt, tenantId } = await req.json();

  const anthropic = new Anthropic();

  const stream = anthropic.messages.stream({
    model: "claude-sonnet-4-20250514",
    max_tokens: 2048,
    messages: [{ role: "user", content: prompt }],
  });

  // Return as Server-Sent Events
  return new Response(
    new ReadableStream({
      async start(controller) {
        for await (const event of stream) {
          if (
            event.type === "content_block_delta" &&
            event.delta.type === "text_delta"
          ) {
            controller.enqueue(
              new TextEncoder().encode(\`data: ${JSON.stringify({ text: event.delta.text })}\n\n\`)
            );
          }
        }
        controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
        controller.close();
      },
    }),
    {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
      },
    }
  );
}

Conclusion

Building AI features into your SaaS isn't rocket science, but it does require solid engineering. The key takeaways:

  1. Start small — one feature, one model, prove the value
  2. Build abstractions — an AI service layer keeps you flexible
  3. Manage costs actively — caching, tiered models, token budgets
  4. Validate everything — AI output is unpredictable, treat it accordingly
  5. Measure and iterate — track which features deliver value

The SaaS products that integrate AI best aren't those with the most features, but those that offer the right features reliably and affordably.

Start today with one concrete use case — and build from there.