Data Components

Generated Ad

Draft a Google Search RSA, a Meta feed ad, or a LinkedIn Sponsored post anchored in how the audience actually talks. Pick a platform, describe what you sell and who it's for, and Exa pulls real audience language, real pain points, and real proof points from the open web before drafting copy that respects each platform's character limits.

Demo

Each draft includes a “Concept” line naming the specific pain or debate the ad is grounded in, with the source favicons inline, so the marketer can verify the copy is rooted in real audience language before uploading to the ad platform.

Responsive Search Ad: 5 headlines (30 char) and 4 descriptions (90 char).

Try one of these:

How it works

One call to exa.search with type: "deep" per draft. The query is shaped per platform: Google Search looks at search-intent language and review-site comparisons, Meta looks at how the audience vents and celebrates on Reddit and social platforms, LinkedIn looks at professional pain points from recent posts, podcasts, and trade press.

The model then writes ad copy that borrows phrasing directly from the sources. The required audienceInsight field forces the model to declare which specific pain or debate it's grounding the ad in, which the renderer surfaces so the marketer can sanity-check before launch.

Each template ships its own schema, system prompt, and character limits in ad-templates.ts. Google Search returns 5 headlines (30 char) and 4 descriptions (90 char), each tagged to a distinct angle: intent, benefit, proof, differentiator, CTA. Meta returns primary text with a hook front-loaded for the “see more” cutoff. LinkedIn returns intro text written for a specific named role.

The prompts explicitly ban the most overused ad clichés (“Supercharge”, “Game-changer”, “Unlock your potential”, “AI-powered” without a specific verb, “Trusted by industry leaders” without a real number) and require specificity from the sources over generic claims. Character counts are checked on the client and highlighted in red when copy exceeds the platform limit, so you catch overruns before uploading.

Source code

"use client";

import { useState } from "react";
import { CitationChip, safeDomain, type Citation } from "@/lib/citations";
import { LIMITS, TEMPLATES, type TemplateId } from "@/lib/ad-templates";

type GenState =
  | { status: "idle" }
  | { status: "generating" }
  | {
      status: "done";
      template: TemplateId;
      ad: GeneratedAd;
      citations: Citation[];
    }
  | { status: "error"; error: string };

type GeneratedAd = {
  audienceInsight?: string;
  // google-search
  headlines?: string[];
  descriptions?: string[];
  // meta-feed, linkedin-feed
  primaryText?: string;
  introText?: string;
  headline?: string;
  description?: string;
  cta?: string;
};

export function GeneratedAdExample() {
  const [template, setTemplate] = useState<TemplateId>("google-search");
  const [topic, setTopic] = useState("");
  const [state, setState] = useState<GenState>({ status: "idle" });

  const active = TEMPLATES.find((t) => t.id === template)!;

  async function generate(tp: string, tpl: TemplateId) {
    const t = tp.trim();
    if (!t || state.status === "generating") return;
    setState({ status: "generating" });
    try {
      const res = await fetch("/api/generated-ad", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ topic: t, 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,
        ad: json.ad ?? {},
        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] overflow-x-auto">
        {TEMPLATES.map((t) => {
          const isActive = t.id === template;
          return (
            <button
              key={t.id}
              type="button"
              onClick={() => {
                setTemplate(t.id);
                setTopic("");
                setState({ status: "idle" });
              }}
              className={`px-3 py-2 text-[13px] -mb-px border-b-2 whitespace-nowrap ${
                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(topic, 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={topic}
            onChange={(e) => setTopic(e.target.value)}
            placeholder={active.placeholder}
            className="flex-1 text-[13px] text-[#111827] focus:outline-none"
          />
          <button
            type="submit"
            disabled={isGenerating || !topic.trim()}
            className="bg-[var(--color-brand)] text-white text-[13px] font-medium px-3.5 py-1.5 disabled:opacity-50"
          >
            {isGenerating ? "Drafting..." : "Draft"}
          </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={() => {
                  setTopic(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">
          <AdSkeleton />
        </div>
      )}

      {state.status === "done" && (
        <AdResult
          template={state.template}
          ad={state.ad}
          citations={state.citations}
        />
      )}
    </div>
  );
}

function AdResult({
  template,
  ad,
  citations,
}: {
  template: TemplateId;
  ad: GeneratedAd;
  citations: Citation[];
}) {
  const uniqueCitations = dedupeByDomain(citations);
  return (
    <div className="space-y-3">
      {(ad.audienceInsight || uniqueCitations.length > 0) && (
        <p className="text-[12px] leading-[1.55] text-[#6b7280]">
          <span className="text-[#9ca3af]">Concept:</span>{" "}
          {asString(ad.audienceInsight)}
          {uniqueCitations.length > 0 && (
            <span className="inline-flex items-center gap-1 ml-1.5 align-middle">
              {uniqueCitations.map((c) => (
                <CitationChip key={c.id} citation={c} />
              ))}
            </span>
          )}
        </p>
      )}

      {template === "google-search" && <GoogleSearchAd ad={ad} />}
      {template === "meta-feed" && <MetaFeedAd ad={ad} />}
      {template === "linkedin-feed" && <LinkedInFeedAd ad={ad} />}
    </div>
  );
}

function GoogleSearchAd({ ad }: { ad: GeneratedAd }) {
  const headlines = (ad.headlines ?? []).map((h) => asString(h)).filter(Boolean);
  const descriptions = (ad.descriptions ?? [])
    .map((d) => asString(d))
    .filter(Boolean);
  const preview = headlines.slice(0, 3).join("  |  ");
  const previewDesc = descriptions.slice(0, 2).join(" ");
  const limit = LIMITS["google-search"];
  return (
    <div className="space-y-4">
      <article className="border border-[#e5e5e5] bg-white px-4 py-4">
        <div className="flex items-center gap-2 text-[12px] text-[#5f6368] mb-1">
          <span className="inline-flex items-center px-1.5 py-px text-[10px] font-medium text-[#202124] border border-[#5f6368] rounded-sm">
            Sponsored
          </span>
          <div className="flex items-center gap-1.5">
            <div className="h-4 w-4 rounded-full bg-[#cfd9de]" />
            <span>yourdomain.com</span>
          </div>
        </div>
        {preview && (
          <a
            href="#"
            onClick={(e) => e.preventDefault()}
            className="block text-[18px] leading-[1.3] text-[#1a0dab] hover:underline"
          >
            {preview}
          </a>
        )}
        {previewDesc && (
          <p className="mt-1 text-[13px] leading-[1.45] text-[#4d5156]">
            {previewDesc}
          </p>
        )}
      </article>

      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <div>
          <div className="text-[11px] uppercase tracking-wider text-[#9ca3af] mb-2">
            Headlines ({headlines.length}/5)
          </div>
          <ul className="space-y-1">
            {headlines.map((h, i) => (
              <CopyLine
                key={i}
                index={i + 1}
                text={h}
                limit={limit.headline}
              />
            ))}
          </ul>
        </div>
        <div>
          <div className="text-[11px] uppercase tracking-wider text-[#9ca3af] mb-2">
            Descriptions ({descriptions.length}/4)
          </div>
          <ul className="space-y-1">
            {descriptions.map((d, i) => (
              <CopyLine
                key={i}
                index={i + 1}
                text={d}
                limit={limit.description}
              />
            ))}
          </ul>
        </div>
      </div>
    </div>
  );
}

function MetaFeedAd({ ad }: { ad: GeneratedAd }) {
  const primary = asString(ad.primaryText);
  const headline = asString(ad.headline);
  const description = asString(ad.description);
  const cta = asString(ad.cta) || "Learn More";
  const limit = LIMITS["meta-feed"];
  return (
    <div className="space-y-3">
      <article className="border border-[#e5e5e5] bg-white">
        <div className="flex items-center gap-2 px-4 py-3">
          <div className="h-10 w-10 rounded-full bg-[#cfd9de] shrink-0" />
          <div className="min-w-0">
            <div className="text-[13px] font-medium text-[#0f1419] leading-tight">
              Your Brand
            </div>
            <div className="text-[11px] text-[#536471] leading-tight">
              Sponsored &middot; <span aria-label="globe">🌐</span>
            </div>
          </div>
        </div>
        {primary && (
          <div className="px-4 pb-3 text-[14px] leading-[1.55] text-[#111827] whitespace-pre-line space-y-2">
            {primary
              .split(/\n+/)
              .map((p) => p.trim())
              .filter(Boolean)
              .map((p, i) => (
                <p key={i}>{p}</p>
              ))}
          </div>
        )}
        <div className="aspect-[1.91/1] bg-[#f3f4f6] border-y border-[#e5e5e5] flex items-center justify-center text-[12px] text-[#9ca3af]">
          Image (1200 × 628)
        </div>
        <div className="flex items-center justify-between px-4 py-3 gap-3">
          <div className="min-w-0">
            <div className="text-[10px] uppercase tracking-wider text-[#9ca3af]">
              yourdomain.com
            </div>
            {headline && (
              <div className="text-[15px] font-medium text-[#111827] leading-tight mt-0.5 truncate">
                {headline}
              </div>
            )}
            {description && (
              <div className="text-[12px] text-[#536471] mt-0.5 truncate">
                {description}
              </div>
            )}
          </div>
          <button
            type="button"
            className="shrink-0 bg-[#e5e7eb] text-[#111827] text-[13px] font-medium px-3 py-1.5 rounded"
          >
            {cta}
          </button>
        </div>
      </article>

      <FieldRow label="Primary text" text={primary} limit={limit.primaryText} />
      <FieldRow label="Headline" text={headline} limit={limit.headline} />
      <FieldRow
        label="Description"
        text={description}
        limit={limit.description}
      />
      <FieldRow label="CTA" text={cta} />
    </div>
  );
}

function LinkedInFeedAd({ ad }: { ad: GeneratedAd }) {
  const intro = asString(ad.introText);
  const headline = asString(ad.headline);
  const description = asString(ad.description);
  const cta = asString(ad.cta) || "Learn More";
  const limit = LIMITS["linkedin-feed"];
  return (
    <div className="space-y-3">
      <article className="border border-[#e5e5e5] bg-white">
        <div className="flex items-center gap-2 px-4 py-3">
          <div className="h-10 w-10 rounded bg-[#cfd9de] shrink-0" />
          <div className="min-w-0">
            <div className="text-[13px] font-medium text-[#0f1419] leading-tight">
              Your Company
            </div>
            <div className="text-[11px] text-[#536471] leading-tight">
              Promoted
            </div>
          </div>
        </div>
        {intro && (
          <div className="px-4 pb-3 text-[14px] leading-[1.55] text-[#111827] whitespace-pre-line">
            {intro}
          </div>
        )}
        <div className="aspect-[1.91/1] bg-[#f3f4f6] border-y border-[#e5e5e5] flex items-center justify-center text-[12px] text-[#9ca3af]">
          Image (1200 × 627)
        </div>
        <div className="flex items-center justify-between px-4 py-3 gap-3">
          <div className="min-w-0">
            {headline && (
              <div className="text-[15px] font-medium text-[#111827] leading-tight">
                {headline}
              </div>
            )}
            {description && (
              <div className="text-[12px] text-[#536471] mt-1">
                {description}
              </div>
            )}
            <div className="text-[11px] text-[#9ca3af] mt-1">
              yourdomain.com
            </div>
          </div>
          <button
            type="button"
            className="shrink-0 border border-[#0a66c2] text-[#0a66c2] text-[13px] font-medium px-3.5 py-1 rounded-full"
          >
            {cta}
          </button>
        </div>
      </article>

      <FieldRow label="Intro text" text={intro} limit={limit.introText} />
      <FieldRow label="Headline" text={headline} limit={limit.headline} />
      <FieldRow
        label="Description"
        text={description}
        limit={limit.description}
      />
      <FieldRow label="CTA" text={cta} />
    </div>
  );
}

function CopyLine({
  index,
  text,
  limit,
}: {
  index: number;
  text: string;
  limit: number;
}) {
  const over = text.length > limit;
  return (
    <li className="flex items-baseline gap-2 text-[13px]">
      <span className="text-[#9ca3af] w-4 shrink-0">{index}.</span>
      <span className="flex-1 text-[#111827]">{text}</span>
      <span
        className={`text-[11px] tabular-nums ${
          over ? "text-[#dc2626]" : "text-[#9ca3af]"
        }`}
      >
        {text.length}/{limit}
      </span>
    </li>
  );
}

function FieldRow({
  label,
  text,
  limit,
}: {
  label: string;
  text: string;
  limit?: number;
}) {
  if (!text) return null;
  const over = limit ? text.length > limit : false;
  return (
    <div>
      <div className="flex items-baseline justify-between gap-2 mb-0.5">
        <span className="text-[12px] text-[#9ca3af]">{label}</span>
        {limit && (
          <span
            className={`text-[11px] tabular-nums ${
              over ? "text-[#dc2626]" : "text-[#9ca3af]"
            }`}
          >
            {text.length}/{limit}
          </span>
        )}
      </div>
      <p className="text-[13px] leading-[1.55] text-[#374151] whitespace-pre-line">
        {text}
      </p>
    </div>
  );
}

function AdSkeleton() {
  return (
    <div className="space-y-2">
      <div className="flex items-center gap-2 mb-3">
        <div className="h-10 w-10 rounded-full bg-[#e5e7eb] animate-pulse" />
        <div className="space-y-1">
          <div className="h-3 w-32 rounded bg-[#e5e7eb] animate-pulse" />
          <div className="h-2 w-24 rounded bg-[#e5e7eb] animate-pulse" />
        </div>
      </div>
      <div className="h-3 w-3/4 rounded bg-[#e5e7eb] animate-pulse" />
      <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-32 w-full rounded bg-[#e5e7eb] animate-pulse" />
      <div className="h-3 w-2/3 rounded bg-[#e5e7eb] animate-pulse" />
    </div>
  );
}

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