Data Components

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;
}