DEV Community

Roberto Luna
Roberto Luna

Posted on

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

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),
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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

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

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)