Generated Page
Pick a template (Listicle, Comparison, Alternatives, FAQ) and Exa returns a structured page object with meta tags, body content, and grounded citations. The same pattern fans out to hundreds of PSEO pages from a list of keywords.
Demo
Pick a template, enter a target keyword, and Exa drafts a fully structured page in one call. Each template has its own outputSchema shaped for its layout.
Top N ranked tools or products for a specific audience.
Try one of these:
How it works
One call to exa.search with type: "deep" per page. The keyword goes into the query, the template defines the outputSchema, and Exa returns a structured object the renderer can drop straight into the layout.
Each template ships its own schema in page-templates.ts. Listicles return a ranked items array. Comparisons return two contenders with pros and cons, plus a verdict. Alternatives return a ranked alternatives list where each summary names the wedge against the focal product. FAQ returns a long faqs array rendered as an accordion.
Every template shares SEO head fields ( metaTitle, intro) and a faqs array sized for the People Also Ask block. Exa caps the deep-search schema at 10 total property names, so each template tunes its 10 carefully.
Sources are flattened from output.grounding, deduped by domain, and rendered as a favicon row beneath the page. To scale, drive a list of keywords through this route and write each result to your CMS or static site.
Source code
"use client";
import { useState } from "react";
import { CitationChip, safeDomain, type Citation } from "@/lib/citations";
import { TEMPLATES, type TemplateId } from "@/lib/page-templates";
type PageState =
| { status: "idle" }
| { status: "generating" }
| { status: "done"; template: TemplateId; page: GeneratedPage; citations: Citation[] }
| { status: "error"; error: string };
type GeneratedPage = {
metaTitle?: string;
metaDescription?: string;
intro?: string;
// listicle
items?: ListicleItem[];
// comparison
contenders?: Contender[];
verdict?: string;
// alternatives
alternatives?: Alternative[];
// shared (and the sole body for "faq")
faqs?: { question: string; answer: string }[];
};
type ListicleItem = { name: string; url: string; summary: string };
type Contender = { name: string; pros: string[]; cons: string[] };
type Alternative = { name: string; url: string; summary: string };
export function GeneratedPageExample() {
const [template, setTemplate] = useState<TemplateId>("listicle");
const [keyword, setKeyword] = useState("");
const [state, setState] = useState<PageState>({ status: "idle" });
const active = TEMPLATES.find((t) => t.id === template)!;
async function generate(kw: string, tpl: TemplateId) {
const k = kw.trim();
if (!k || state.status === "generating") return;
setState({ status: "generating" });
try {
const res = await fetch("/api/generated-page", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ keyword: k, template: tpl }),
});
const json = await res.json();
if (!res.ok || json.error) {
setState({
status: "error",
error: json.error || `Request failed (${res.status})`,
});
return;
}
setState({
status: "done",
template: tpl,
page: json.page ?? {},
citations: json.citations ?? [],
});
} catch (err) {
setState({
status: "error",
error: err instanceof Error ? err.message : String(err),
});
}
}
const isGenerating = state.status === "generating";
return (
<div className="space-y-4">
<div className="flex items-center gap-1 border-b border-[#e5e5e5]">
{TEMPLATES.map((t) => {
const isActive = t.id === template;
return (
<button
key={t.id}
type="button"
onClick={() => {
setTemplate(t.id);
setKeyword("");
setState({ status: "idle" });
}}
className={`px-3 py-2 text-[13px] -mb-px border-b-2 ${
isActive
? "border-[var(--color-brand)] text-[#111827] font-medium"
: "border-transparent text-[#6b7280] hover:text-[#111827]"
}`}
>
{t.label}
</button>
);
})}
</div>
<p className="text-[13px] text-[#6b7280]">{active.tagline}</p>
<form
onSubmit={(e) => {
e.preventDefault();
void generate(keyword, template);
}}
className="border border-[#e5e5e5] bg-white overflow-hidden focus-within:border-[var(--color-brand)]"
>
<div className="flex items-center gap-2 px-3 py-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={active.placeholder}
className="flex-1 text-[13px] text-[#111827] focus:outline-none"
/>
<button
type="submit"
disabled={isGenerating || !keyword.trim()}
className="bg-[var(--color-brand)] text-white text-[13px] font-medium px-3.5 py-1.5 disabled:opacity-50"
>
{isGenerating ? "Generating..." : "Generate"}
</button>
</div>
</form>
{state.status === "idle" && (
<div className="text-[13px] text-[#6b7280]">
<p className="mb-2">Try one of these:</p>
<div className="flex flex-wrap gap-2">
{active.examples.map((s) => (
<button
key={s}
type="button"
onClick={() => {
setKeyword(s);
void generate(s, template);
}}
className="rounded-full border border-[#e5e5e5] bg-white px-3 py-1 text-[12px] text-[#374151] hover:border-[var(--color-brand)] hover:text-[#111827]"
>
{s}
</button>
))}
</div>
</div>
)}
{state.status === "error" && (
<div className="text-[13px] text-[#b91c1c]">{state.error}</div>
)}
{state.status === "generating" && (
<div className="border border-[#e5e5e5] bg-white px-6 py-5">
<PageSkeleton />
</div>
)}
{state.status === "done" && (
<PageCard
template={state.template}
page={state.page}
citations={state.citations}
/>
)}
</div>
);
}
function PageCard({
template,
page,
citations,
}: {
template: TemplateId;
page: GeneratedPage;
citations: Citation[];
}) {
const uniqueCitations = dedupeByDomain(citations);
return (
<div className="border border-[#e5e5e5] bg-white">
{template !== "faq" && (
<SeoHead
title={asString(page.metaTitle)}
description={asString(page.metaDescription)}
/>
)}
<div className="px-8 py-7">
{template !== "faq" && page.metaTitle && (
<h1 className="font-serif text-[28px] leading-[1.2] font-medium tracking-tight text-[#111827] mb-3">
{asString(page.metaTitle)}
</h1>
)}
{template !== "faq" && page.intro && (
<p className="text-[14px] leading-[1.7] text-[#374151] mb-6">
<InlineMd text={asString(page.intro)} />
</p>
)}
{template === "listicle" && <ListicleBody page={page} />}
{template === "comparison" && <ComparisonBody page={page} />}
{template === "alternatives" && <AlternativesBody page={page} />}
{page.faqs && page.faqs.length > 0 && (
<FaqsSection faqs={page.faqs} primary={template === "faq"} />
)}
</div>
{uniqueCitations.length > 0 && (
<div className="px-8 py-3 border-t border-[#e5e5e5] flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center h-4 text-[11px] uppercase tracking-wider text-[#9ca3af] leading-none mr-1">
Sources
</span>
<span className="inline-flex items-center gap-1">
{uniqueCitations.map((c) => (
<CitationChip key={c.id} citation={c} />
))}
</span>
</div>
)}
</div>
);
}
function SeoHead({
title,
description,
}: {
title: string;
description: string;
}) {
if (!title && !description) return null;
return (
<div className="px-8 py-3 border-b border-[#e5e5e5] bg-[#f9fafb] space-y-1">
<div className="text-[10px] uppercase tracking-wider text-[#9ca3af]">
SEO head
</div>
{title && (
<div className="text-[13px] text-[#1a0dab] font-medium truncate">
{title}
</div>
)}
{description && (
<div className="text-[12px] text-[#4d5156] leading-[1.5]">
{description}
</div>
)}
</div>
);
}
function ListicleBody({ page }: { page: GeneratedPage }) {
if (!page.items?.length) return null;
return (
<ol className="space-y-5">
{page.items.map((item, i) => (
<li key={i} className="flex gap-4">
<div className="text-[20px] font-serif font-medium text-[var(--color-brand)] leading-none pt-1 w-6 shrink-0">
{i + 1}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
<a
href={asString(item.url)}
target="_blank"
rel="noopener noreferrer"
className="text-[15px] font-medium text-[#111827] hover:underline"
>
{asString(item.name)}
</a>
{item.url && (
<span className="text-[11px] text-[#9ca3af]">
{safeDomain(asString(item.url))}
</span>
)}
</div>
<p className="text-[13px] leading-[1.65] text-[#374151] whitespace-pre-line">
<InlineMd text={asString(item.summary)} />
</p>
</div>
</li>
))}
</ol>
);
}
function ComparisonBody({ page }: { page: GeneratedPage }) {
if (!page.contenders?.length) return null;
return (
<div className="space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{page.contenders.map((c, i) => (
<div key={i} className="border border-[#e5e5e5] p-4">
<div className="text-[15px] font-medium text-[#111827]">
{asString(c.name)}
</div>
<ProsConsList label="Pros" items={c.pros} tone="pos" />
<ProsConsList label="Cons" items={c.cons} tone="neg" />
</div>
))}
</div>
{page.verdict && (
<div className="border-l-2 border-[var(--color-brand)] pl-4 py-1">
<div className="text-[11px] uppercase tracking-wider text-[#9ca3af] mb-1">
Verdict
</div>
<p className="text-[13px] leading-[1.7] text-[#111827]">
<InlineMd text={asString(page.verdict)} />
</p>
</div>
)}
</div>
);
}
function ProsConsList({
label,
items,
tone,
}: {
label: string;
items: string[];
tone: "pos" | "neg";
}) {
if (!items?.length) return null;
const marker = tone === "pos" ? "+" : "−";
const color = tone === "pos" ? "text-[#15803d]" : "text-[#b45309]";
return (
<div className="mt-3">
<div className="text-[11px] uppercase tracking-wider text-[#9ca3af] mb-1">
{label}
</div>
<ul className="space-y-1">
{items.map((it, i) => (
<li key={i} className="text-[12px] leading-[1.55] text-[#374151] flex gap-2">
<span className={`${color} font-medium`}>{marker}</span>
<span>{asString(it)}</span>
</li>
))}
</ul>
</div>
);
}
function AlternativesBody({ page }: { page: GeneratedPage }) {
if (!page.alternatives?.length) return null;
return (
<ol className="space-y-5">
{page.alternatives.map((a, i) => (
<li key={i} className="flex gap-4">
<div className="text-[20px] font-serif font-medium text-[var(--color-brand)] leading-none pt-1 w-6 shrink-0">
{i + 1}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
<a
href={asString(a.url)}
target="_blank"
rel="noopener noreferrer"
className="text-[15px] font-medium text-[#111827] hover:underline"
>
{asString(a.name)}
</a>
{a.url && (
<span className="text-[11px] text-[#9ca3af]">
{safeDomain(asString(a.url))}
</span>
)}
</div>
<p className="text-[13px] leading-[1.65] text-[#374151] whitespace-pre-line">
<InlineMd text={asString(a.summary)} />
</p>
</div>
</li>
))}
</ol>
);
}
function FaqsSection({
faqs,
primary,
}: {
faqs: { question: string; answer: string }[];
primary?: boolean;
}) {
return (
<div className={primary ? "" : "mt-8"}>
{!primary && (
<h2 className="text-[16px] font-medium text-[#111827] mb-3">
Frequently asked
</h2>
)}
<div className="border-t border-[#e5e5e5]">
{faqs.map((f, i) => (
<details
key={i}
className="group border-b border-[#e5e5e5]"
>
<summary className="flex items-start justify-between gap-3 py-3.5 cursor-pointer list-none text-[14px] font-medium text-[#111827] hover:text-[var(--color-brand)]">
<span className="flex-1 min-w-0">{asString(f.question)}</span>
<svg
viewBox="0 0 12 12"
aria-hidden="true"
className="w-3 h-3 mt-1 shrink-0 text-[#9ca3af] transition-transform group-open:rotate-180"
>
<path
d="M2 4.5l4 4 4-4"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</summary>
<p className="pb-4 text-[13px] leading-[1.7] text-[#374151]">
{asString(f.answer)}
</p>
</details>
))}
</div>
</div>
);
}
function PageSkeleton() {
return (
<div className="space-y-3">
<div className="h-5 w-2/3 rounded bg-[#e5e7eb] animate-pulse" />
<div className="h-3 w-1/2 rounded bg-[#e5e7eb] animate-pulse" />
<div className="h-2" />
<div className="h-3 w-full rounded bg-[#e5e7eb] animate-pulse" />
<div className="h-3 w-11/12 rounded bg-[#e5e7eb] animate-pulse" />
<div className="h-3 w-4/5 rounded bg-[#e5e7eb] animate-pulse" />
<div className="h-2" />
<div className="h-3 w-1/3 rounded bg-[#e5e7eb] animate-pulse" />
<div className="h-3 w-5/6 rounded bg-[#e5e7eb] animate-pulse" />
<div className="h-3 w-3/4 rounded bg-[#e5e7eb] animate-pulse" />
</div>
);
}
// Render a string of prose with inline [anchor](https://...)
// markdown links converted to anchor tags. Bare URLs and other
// markdown features are left as plain text — the model is
// instructed to emit only inline links.
function InlineMd({ text }: { text: string }) {
const parts: React.ReactNode[] = [];
const re = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
let last = 0;
let m: RegExpExecArray | null;
let i = 0;
while ((m = re.exec(text)) !== null) {
if (m.index > last) parts.push(text.slice(last, m.index));
parts.push(
<a
key={i++}
href={m[2]}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--color-brand)] underline decoration-[var(--color-brand)]/30 underline-offset-2 hover:decoration-[var(--color-brand)]"
>
{m[1]}
</a>
);
last = re.lastIndex;
}
if (last < text.length) parts.push(text.slice(last));
return <>{parts}</>;
}
// Deep search occasionally emits a string field as a nested
// object ({value: "..."} or similar). Coerce to a plain string
// so the renderer never prints [object Object].
function asString(v: unknown): string {
if (typeof v === "string") return v;
if (v == null) return "";
if (typeof v === "object") {
const o = v as Record<string, unknown>;
if (typeof o.value === "string") return o.value;
if (typeof o.name === "string") return o.name;
if (typeof o.text === "string") return o.text;
}
return String(v);
}
function dedupeByDomain(citations: Citation[]): Citation[] {
const seen = new Set<string>();
const unique: Citation[] = [];
for (const c of citations) {
const d = safeDomain(c.url);
if (seen.has(d)) continue;
seen.add(d);
unique.push(c);
}
return unique;
}