Data Components
Enriched Table
Many GTM tools are tables: CRM views, prospect lists, account dashboards, monitor inboxes. Each row is populated with the result of one Exa search call, and each column maps to a field in your outputSchema.
Demo
Paste a list of companies, one per line, and hit Enrich. Each row fires its own Exa call in parallel and fills in on its own clock.
How it works
Each row is the result of one exa.search call with type: "deep" and an outputSchema of type: "object". Columns map one-to-one to the properties on that schema. See Data Enrichment for recommended schemas.
Fire one call per row in parallel. Each row carries its own status (enriching, done, error) so the UI can render rows independently as promises settle.
Source code
"use client";
import { useState } from "react";
import { TABLE_COLUMNS, type TableColumn } from "@/lib/table-columns";
type RowState =
| { status: "enriching" }
| { status: "done"; data: Record<string, string | number | string[]> }
| { status: "error"; error: string };
type Row = {
id: string;
input: string;
state: RowState;
};
const DEFAULT_INPUTS = "Stripe\nNotion\nLinear\nVercel";
export function TableExample() {
const [text, setText] = useState(DEFAULT_INPUTS);
const [rows, setRows] = useState<Row[]>([]);
const [running, setRunning] = useState(false);
function parseInputs(s: string): string[] {
return s
.split(/[\n,]+/)
.map((x) => x.trim())
.filter(Boolean);
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (running) return;
const inputs = parseInputs(text);
if (inputs.length === 0) return;
setRunning(true);
const initial: Row[] = inputs.map((input, i) => ({
id: `row_${i}`,
input,
state: { status: "enriching" },
}));
setRows(initial);
const derivedPayload = TABLE_COLUMNS.map((f) => ({
id: f.id,
description: f.description,
outputType: f.outputType,
}));
await Promise.all(
initial.map(async (row) => {
try {
const res = await fetch("/api/enrich-form", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ seed: row.input, derived: derivedPayload }),
});
const json = await res.json();
if (!res.ok || json.error) {
patchRow(row.id, {
status: "error",
error: json.error || `Request failed (${res.status})`,
});
return;
}
patchRow(row.id, { status: "done", data: json.data ?? {} });
} catch (err) {
patchRow(row.id, {
status: "error",
error: err instanceof Error ? err.message : String(err),
});
}
})
);
setRunning(false);
}
function patchRow(id: string, state: RowState) {
setRows((prev) => prev.map((r) => (r.id === id ? { ...r, state } : r)));
}
return (
<div className="space-y-4">
<form
onSubmit={onSubmit}
className="border border-[#e5e5e5] bg-white overflow-hidden focus-within:border-[var(--color-brand)]"
>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
className="block w-full text-[13px] text-[#111827] px-3 py-2.5 focus:outline-none resize-none"
placeholder="One company per line, or comma-separated"
/>
<div className="flex items-center justify-between border-t border-[#e5e5e5] bg-[#fafafa] px-3 py-2">
<p className="text-[12px] text-[#6b7280]">
One Exa call per row, fired in parallel.
</p>
<button
type="submit"
disabled={running || parseInputs(text).length === 0}
className="bg-[var(--color-brand)] text-white text-[13px] font-medium px-3.5 py-1.5 disabled:opacity-50"
>
{running ? "Enriching..." : "Enrich rows"}
</button>
</div>
</form>
{rows.length > 0 && (
<div className="border border-[#e5e5e5] overflow-hidden">
<table className="w-full text-[14px] border-collapse">
<thead>
<tr className="bg-[#fafafa]">
<Th className="w-[160px]">Company</Th>
{TABLE_COLUMNS.map((f) => (
<Th key={f.id} className={f.width}>
{f.label}
</Th>
))}
<Th className="w-[110px]">Status</Th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => {
const s = r.state;
return (
<tr
key={r.id}
className={
i < rows.length - 1 ? "border-b border-[#e5e5e5]" : ""
}
>
<td className="py-2.5 px-4 font-mono text-[13px] text-[#111827] align-top">
{r.input}
</td>
{s.status === "done" &&
TABLE_COLUMNS.map((f) => (
<Td key={f.id}>{formatValue(f, s.data[f.id])}</Td>
))}
{s.status === "enriching" &&
TABLE_COLUMNS.map((f) => <ShimmerCell key={f.id} />)}
{s.status === "error" && (
<td
colSpan={TABLE_COLUMNS.length}
className="py-2.5 px-4 text-[13px] text-[#b91c1c] align-top"
>
{s.error}
</td>
)}
<td className="py-2.5 px-4 align-top">
<StatusBadge status={s.status} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
function formatValue(
f: TableColumn,
v: string | number | string[] | undefined
) {
if (v === undefined || v === null) return "—";
if (Array.isArray(v)) return v.join(", ");
if (f.id === "revenue" && typeof v === "number") return formatMoney(v);
if (f.id === "employees" && typeof v === "number")
return v.toLocaleString("en-US");
return String(v);
}
function formatMoney(n: number) {
if (n >= 1_000_000_000) return `$${(n / 1_000_000_000).toFixed(1)}B`;
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(0)}M`;
if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`;
return `$${n}`;
}
function Th({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<th
className={`text-left py-2.5 px-4 font-medium text-[#888888] text-[12px] uppercase tracking-wider border-b border-[#e5e5e5] ${className}`}
>
{children}
</th>
);
}
function Td({ children }: { children: React.ReactNode }) {
return (
<td className="py-2.5 px-4 text-[#374151] align-top leading-[1.5]">
{children}
</td>
);
}
function ShimmerCell() {
return (
<td className="py-2.5 px-4 align-top">
<div className="h-3 w-2/3 rounded bg-[#e5e7eb] animate-pulse" />
</td>
);
}
function StatusBadge({ status }: { status: RowState["status"] }) {
const styles: Record<RowState["status"], string> = {
done: "bg-[#dcfce7] text-[#166534]",
enriching: "bg-[#dbeafe] text-[#1e40af]",
error: "bg-[#fee2e2] text-[#991b1b]",
};
return (
<span
className={`inline-block rounded px-2 py-0.5 text-[11px] font-medium ${styles[status]}`}
>
{status}
</span>
);
}