Adding Groq‑Powered AI Summaries to the ISO‑27001 Dashboard with Daily Usage Limits

TL;DR: Integrated server‑side Groq calls to generate daily AI summaries for each analysis page, added a usage‑counter middleware and UI components (AiButton, AiText). This keeps API keys secret, enforces a per‑day quota, and surfaces results without a full page reload.

The Problem

The ISO‑27001 dashboard lets auditors view controls, policies, and daily logs. A recurring request from the team was to get an AI‑generated summary of each analysis (/analisis/[id]) that highlighted gaps and suggested next steps.

Two technical blockers surfaced:

  1. Secure AI calls: The Groq API key was stored in the client bundle in an early prototype, causing CORS errors and exposing the secret.
  2. Unlimited usage: Groq charges per token, so we needed a hard daily limit per deployment to avoid runaway costs.

The symptom was a broken “Generate Summary” button that either threw a 401 Unauthorized (key exposed) or let anyone hammer the endpoint until the quota was exhausted, resulting in 429 Too Many Requests from Groq.

What I Tried First

My first attempt was a client‑side fetch from components/ai-button.tsx:

// early prototype (removed) const resp = await fetch("https://api.groq.com/openai/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_GROQ_KEY}` }, body: JSON.stringify(payload), }); 

Issues:

  • NEXT_PUBLIC_GROQ_KEY leaked into the bundle, visible in Chrome dev tools.
  • The request hit CORS restrictions because Groq does not allow the origin localhost:3000.
  • No server‑side throttling; every click hit the API directly.

I also tried wrapping the fetch in a Next.js API route (pages/api/ai.ts) but hit the same quota‑blow problem because the route lacked any persistence of usage counts.

Result: the button either failed with a 401 or kept consuming tokens until the daily budget was spent, after which every call returned a 429.

The Implementation

1. Store Secrets Server‑Side

Added the Groq key to .env.example (never committed the real value):

+ GROQ_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxx + AI_DAILY_LIMIT=2000 # token budget per day 

The production .env now contains the real key, and the code only accesses it via process.env.GROQ_API_KEY on the server.

2. Central AI Helper (lib/ai.ts)

Created a thin wrapper around the Groq client that also tracks daily usage:

// lib/ai.ts import { Groq } from "groq-sdk"; import { db } from "@/lib/db"; export const groq = new Groq({ apiKey: process.env.GROQ_API_KEY }); export const aiEnabled = process.env.GROQ_API_KEY !== undefined; export const dailyLimit = Number(process.env.AI_DAILY_LIMIT ?? 0); /** Returns true if we have quota left for today */ export async function canUseAi(): Promise<boolean> { const { used } = await db .selectFrom("ai_usage") .select("used") .where("date", "=", new Date().toISOString().slice(0, 10)) .executeTakeFirst(); return (used ?? 0) < dailyLimit; } /** Increment usage counter */ export async function recordAiUsage(tokens: number) { await db .insertInto("ai_usage") .values({ date: new Date().toISOString().slice(0, 10), used: db.raw(`COALESCE(used,0) + ${tokens}`), }) .onConflictDoUpdate({ target: ["date"], set: { used: db.raw(`ai_usage.used + ${tokens}`) }, }) .execute(); } 

The ai_usage table is defined in sql/schema.sql:

+ CREATE TABLE ai_usage ( + date DATE PRIMARY KEY, + used INTEGER NOT NULL DEFAULT 0 + ); 

3. Server Action (app/(app)/analisis/actions.ts)

Next.js 13 server actions let us call the AI from a form without an explicit API route:

// app/(app)/analisis/actions.ts "use server"; import { redirect } from "next/navigation"; import { RuleError, str, withParam, errorMessage } from "@/lib/action-util"; import { aiEnabled, aiModel, buildContext, canUseAi, recordAiUsage, groq } from "@/lib/ai"; export async function generateSummary(id: string) { if (!aiEnabled) throw new RuleError("AI not configured"); if (!(await canUseAi())) { throw new RuleError("Daily AI quota exhausted"); } const context = await buildContext(id); const resp = await groq.chat.completions.create({ model: aiModel, messages: [{ role: "system", content: context }], }); const tokens = resp.usage?.total_tokens ?? 0; await recordAiUsage(tokens); // Store the result in the DB (pseudo code) await db .updateTable("analisis") .set({ ai_summary: resp.choices[0].message.content }) .where("id", "=", id) .execute(); redirect(`/analisis/${id}`); } 

Key points:

  • "use server" guarantees the function runs only on the server, keeping the API key safe.
  • canUseAi() checks the daily limit before any request.
  • recordAiUsage() updates the ai_usage table atomically.
  • Errors are thrown as RuleError and surfaced by the UI component.

4. UI Components

components/ai-button.tsx

// components/ai-button.tsx import { useTransition } from "react"; import { generateSummary } from "@/app/(app)/analisis/actions"; export function AiButton({ id }: { id: string }) { const [isPending, startTransition] = useTransition(); return ( <button disabled={isPending} onClick={() => startTransition(() => generateSummary(id))} className="btn-primary" > {isPending ? "Generating…" : "Generate AI Summary"} </button> ); } 

The button triggers the server action directly, avoiding a separate fetch call.

components/ai-text.tsx

// components/ai-text.tsx export function AiText({ text }: { text: string }) { return <p className="prose prose-sm mt-2">{text}</p>; } 

Used in the analysis detail page to render the stored summary.

5. Page Integration

Added the button and text to the analysis detail page:

 diff + import { AiButton } from "@/components/ai-button"; + import { AiText } from "@/components/ai-text"; ... + <AiButton id={params.id} /> + {analysis.ai_summary && --- *Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.* *Repo: `zaerohell/iso27001-tablero` · 2026-10-17* \#playadev #buildinpublic