This guide covers four patterns for wiring AI content into the SecurAtlas Next.js app. All examples use the Supabase client and the RPCs documented in the API Reference.
Pattern 1: Display Narrative (Server Component)
Use rpc_get_or_request_narrative to fetch the cached narrative. If none exists or the cache is stale, the RPC enqueues a generation job and returns freshness: 'none' or freshness: 'stale'.
// app/(app)/t/[tenantId]/(workspace)/dashboard/_components/gap-narrative.tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
interface NarrativeResponse {
narrative: string | null;
generated_at: string | null;
provider: string | null;
fallback_used: boolean;
freshness: 'none' | 'fresh' | 'stale';
pending_regen: boolean;
}
export async function GapNarrative({ tenantId }: { tenantId: string }) {
const supabase = getSupabaseServerClient();
const { data } = await supabase.rpc('rpc_get_or_request_narrative', {
p_tenant_id: tenantId,
p_force_refresh: false,
});
const result = data as NarrativeResponse | null;
if (!result?.narrative) {
return (
<div className="text-sm text-muted-foreground">
{result?.pending_regen
? 'Generating narrative...'
: 'No narrative available yet.'}
</div>
);
}
return (
<div className="prose prose-sm dark:prose-invert">
<p>{result.narrative}</p>
{result.freshness === 'stale' && (
<p className="text-xs text-amber-500">
Updating in background...
</p>
)}
</div>
);
}
Call the same RPC with force_refresh: true to enqueue a new generation job regardless of cache state.
'use client';
import { useState } from 'react';
import { RefreshCw } from 'lucide-react';
import { getSupabaseBrowserClient } from '@kit/supabase/browser-client';
export function RegenerateNarrativeButton({ tenantId }: { tenantId: string }) {
const [loading, setLoading] = useState(false);
async function handleRegenerate() {
setLoading(true);
const supabase = getSupabaseBrowserClient();
await supabase.rpc('rpc_get_or_request_narrative', {
p_tenant_id: tenantId,
p_force_refresh: true,
});
setLoading(false);
// The narrative will appear via real-time subscription (Pattern 3)
// or on next page load
}
return (
<button
onClick={handleRegenerate}
disabled={loading}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground
hover:text-foreground transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3 h-3 ${loading ? 'animate-spin' : ''}`} />
{loading ? 'Requesting...' : 'Regenerate'}
</button>
);
}
Pattern 3: Real-Time Updates
Subscribe to the ai_gap_narratives table via Supabase Realtime to update the UI when a new narrative is generated.
'use client';
import { useEffect, useState } from 'react';
import { getSupabaseBrowserClient } from '@kit/supabase/browser-client';
export function useNarrativeRealtime(tenantId: string) {
const [narrative, setNarrative] = useState<string | null>(null);
const [generatedAt, setGeneratedAt] = useState<string | null>(null);
useEffect(() => {
const supabase = getSupabaseBrowserClient();
const channel = supabase
.channel(`narrative:${tenantId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'ai_gap_narratives',
filter: `tenant_id=eq.${tenantId}`,
},
(payload) => {
const row = payload.new as any;
if (row?.narrative) {
setNarrative(row.narrative);
setGeneratedAt(row.generated_at);
}
},
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [tenantId]);
return { narrative, generatedAt };
}
Realtime subscriptions require that the table has Realtime enabled in the Supabase dashboard (Database > Replication). ai_gap_narratives is already enabled.
Pattern 4: AI Health Status Badge
Display the overall health of the AI content system using rpc_get_ai_health_summary.
import { getSupabaseServerClient } from '@kit/supabase/server-client';
interface HealthSummary {
queue_depth: number;
active_providers: number;
total_providers: number;
circuits_open: number;
last_generation_at: string | null;
}
export async function AiStatusBadge() {
const supabase = getSupabaseServerClient();
const { data } = await supabase.rpc('rpc_get_ai_health_summary');
const health = data as HealthSummary | null;
if (!health) return null;
const isHealthy = health.circuits_open === 0 && health.active_providers > 0;
return (
<div className="flex items-center gap-1.5 text-xs">
<div
className={`w-1.5 h-1.5 rounded-full ${
isHealthy ? 'bg-emerald-500' : 'bg-amber-500'
}`}
/>
<span className="text-muted-foreground">
AI: {health.active_providers}/{health.total_providers} providers
{health.queue_depth > 0 && ` · ${health.queue_depth} queued`}
</span>
</div>
);
}