1) Server: generate SVG via OpenAI text model

We’ll use OpenAI gpt-4.1 (or gpt-4.1-mini) to write the SVG markup. The prompt forces: no <text>, only vector shapes (paths, rect, circle, polygon, gradients, clipPaths), single viewBox="0 0 1024 1024", and colors limited to your palette.

Install:

npm i openai svgo


pages/api/generate-svg-logo.ts

import type { NextApiRequest, NextApiResponse } from "next";
import OpenAI from "openai";
import { optimize } from "svgo";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });

const SVGO_CONFIG = {
  multipass: true,
  plugins: [
    "removeDoctype",
    "removeXMLProcInst",
    "removeComments",
    "removeMetadata",
    "removeEditorsNSData",
    "cleanupAttrs",
    "minifyStyles",
    "cleanupIds",
    "removeScriptElement",
    "removeUnknownsAndDefaults",
    "removeUselessDefs",
    "convertPathData",
    "mergePaths",
  ],
};

// very strict allow-list check (post-optimize)
function softSanitize(svg: string) {
  // Disallow any text / foreign content / external refs
  const banned = /(<!|<script|<foreignObject|xlink:href=|href=("|')http)/i;
  if (banned.test(svg)) throw new Error("Unsafe SVG content");
  // Remove any <text> if model tried it
  svg = svg.replace(/<text[\s\S]*?<\/text>/gi, "");
  return svg;
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  try {
    if (req.method !== "POST") return res.status(405).end();

    const {
      prompt,
      palette = {}, // { primary, secondary, accent, highlight, neutral, surface, textLight, textDark }
      model = "gpt-4.1",
      complexity = "medium", // "simple" | "medium" | "complex"
    } = req.body || {};

    if (!prompt) return res.status(400).json({ error: "Missing prompt" });

    // Limit colors to a safe set (hex list)
    const colors: string[] = [
      palette.primary, palette.secondary, palette.accent, palette.highlight,
      palette.neutral, palette.surface, palette.textLight, palette.textDark,
    ].filter(Boolean);

    const colorList = colors.length ? colors.join(", ") : "#111827, #f8fafc, #2563eb, #10b981";

    const system = [
      "You output ONLY valid SVG markup.",
      'Return a single <svg> element with viewBox="0 0 1024 1024".',
      "Use only vector shapes: <g>, <path>, <rect>, <circle>, <ellipse>, <polygon>, <polyline>, <line>, <defs>, <linearGradient>, <radialGradient>, <clipPath>.",
      "Absolutely NO <text>, NO <image>, NO <foreignObject>, NO external links, NO scripts.",
      "All fills and strokes must use ONLY these hex colors: " + colorList + ".",
      "Design style: logo mark (no words). Clean, balanced, works at small sizes.",
      `Complexity level: ${complexity}. Keep paths reasonably optimized.`,
      "Provide subtle negative space if helpful; include simple gradients or clipping if tasteful.",
      "Leave natural padding (≈6–10% inside the artboard).",
    ].join("\n");

    const user = `Create a vector logo mark for: ${prompt}.
Return ONLY the SVG markup—no backticks, no prose.`;

    const resp = await openai.chat.completions.create({
      model,
      messages: [
        { role: "system", content: system },
        { role: "user", content: user },
      ],
      temperature: 0.6,
    });

    let svg = resp.choices?.[0]?.message?.content?.trim() || "";
    // Remove markdown fences if the model added them
    svg = svg.replace(/^```(?:svg)?/i, "").replace(/```$/i, "").trim();

    // Must be a single <svg>
    if (!/^<svg[\s\S]*<\/svg>\s*$/i.test(svg)) {
      throw new Error("Model did not return a single <svg> element.");
    }

    // Optimize & sanitize
    const optimized = optimize(svg, SVGO_CONFIG);
    if ("data" in optimized === false) throw new Error("SVGO failed");
    let clean = (optimized as any).data as string;

    clean = softSanitize(clean);

    // Enforce viewBox
    clean = clean.replace(
      /<svg([^>]*?)>/i,
      (m, attrs) => {
        // remove width/height to let it scale via viewBox
        let a = attrs
          .replace(/\swidth="[^"]*"/i, "")
          .replace(/\sheight="[^"]*"/i, "");
        if (!/viewBox="/i.test(a)) a += ' viewBox="0 0 1024 1024"';
        return `<svg${a}>`;
      }
    );

    return res.status(200).json({ svg: clean });
  } catch (e: any) {
    console.error(e);
    return res.status(500).json({ error: e?.message ?? "Internal error" });
  }
}


Model note: we’re now using OpenAI gpt-4.1 (or set model:"gpt-4.1-mini" in the request to save cost). Earlier, the raster images used gpt-image-1. For vectors, we must use a text model that emits SVG markup.

2) Client util: call the SVG endpoint + preview

src/utils/svgLogo.ts

export async function generateVectorSVG(options: {
  prompt: string;
  palette: Record<string, string>;
  complexity?: "simple" | "medium" | "complex";
  model?: "gpt-4.1" | "gpt-4.1-mini";
}): Promise<string> {
  const res = await fetch("/api/generate-svg-logo", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(options),
  });
  if (!res.ok) throw new Error(await res.text());
  const { svg } = await res.json();
  return svg as string;
}

Preview component (safe-ish)

Inside your Step-3 “Generate your image” card, swap the PNG flow for:

import { generateVectorSVG } from "../../utils/svgLogo";

const [iconSvg, setIconSvg] = useState<string | null>(null);
const [genBusy, setGenBusy] = useState(false);

<button
  onClick={async () => {
    setGenBusy(true);
    try {
      const svg = await generateVectorSVG({
        prompt: iconPrompt,
        palette,                 // pass your 8-color palette
        complexity: "complex",   // or "simple"/"medium"
        model: "gpt-4.1",        // or "gpt-4.1-mini"
      });
      setIconSvg(svg);
    } finally {
      setGenBusy(false);
    }
  }}
  className="px-4 py-2 rounded-lg bg-neutral-900 text-white hover:bg-black disabled:opacity-60"
  disabled={genBusy}
>
  {genBusy ? "Generating…" : "Generate mark (SVG)"}
</button>

{/* SVG preview with checkerboard behind */}
<div
  className="rounded-lg border p-3 grid place-items-center"
  style={{
    background:
      "conic-gradient(#f8fafc 25%, #eef2f7 0 50%, #f8fafc 0 75%, #eef2f7 0) 0 0/16px 16px",
    minHeight: 240,
  }}
>
  {iconSvg ? (
    <div
      className="w-full max-w-[360px]"
      // eslint-disable-next-line react/no-danger
      dangerouslySetInnerHTML={{ __html: iconSvg }}
    />
  ) : (
    <div className="text-xs text-neutral-500">No SVG yet.</div>
  )}
</div>

Download as .svg

Your LogoCard download handler can write the raw svg string to a Blob:

const blob = new Blob([iconSvg], { type: "image/svg+xml;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "logo-mark.svg";
a.click();
URL.revokeObjectURL(a.href);

3) Compose mark + wordmark into vector SVG (optional final export)

You already separate font choice (wordmark) and icon. If you want a fully vector export that contains both, create a composed SVG (icon on left + <text> on right in your chosen font & color):

src/utils/svgCompose.ts

export function composeMarkAndWordmarkSVG(opts: {
  markSvg: string;          // inner <svg>…</svg> string
  brand: string;            // wordmark text
  fontFamily: string;       // chosen font family
  textColor: string;        // hex
  layout?: "left" | "top";
  canvasW?: number;
  canvasH?: number;
}): string {
  const layout = opts.layout ?? "left";
  const W = opts.canvasW ?? (layout === "left" ? 1200 : 1024);
  const H = opts.canvasH ?? (layout === "left" ? 512 : 1024);

  // strip outer svg wrapper of the mark and keep its children
  const inner = (opts.markSvg.match(/<svg[^>]*>([\s\S]*?)<\/svg>/i)?.[1] ?? "").trim();

  const iconBox = layout === "left"
    ? { x: 40, y: 40, w: 432, h: H - 80 }
    : { x: (W - 512) / 2, y: 40, w: 512, h: 512 };

  const textX = layout === "left" ? iconBox.x + iconBox.w + 40 : W / 2;
  const textY = layout === "left" ? H / 2 + 12 : iconBox.y + iconBox.h + 80;

  const textAlign = layout === "left" ? 'text-anchor="start"' : 'text-anchor="middle"';
  const fontSize = layout === "left" ? Math.min(140, H * 0.32) : 120;

  // scale the mark’s content into iconBox via a group wrapper
  const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}">
  <defs />
  <g transform="translate(${iconBox.x}, ${iconBox.y})">
    <svg x="0" y="0" width="${iconBox.w}" height="${iconBox.h}" viewBox="0 0 1024 1024">
      ${inner}
    </svg>
  </g>
  <text x="${textX}" y="${textY}" ${textAlign}
        font-family=${JSON.stringify(opts.fontFamily)}
        font-weight="700"
        font-size="${fontSize}"
        fill="${opts.textColor}">
    ${opts.brand}
  </text>
</svg>`.trim();

  return svg;
}


Use it in your Combine button instead of the PNG composer:

import { composeMarkAndWordmarkSVG } from "../../utils/svgCompose";

const combinedSvg = composeMarkAndWordmarkSVG({
  markSvg: iconSvg!,
  brand: name,
  fontFamily,
  textColor: resolveTextColor(),
  layout: combineLayout, // "left" | "top"
});

// add to logos as an SVG asset
setLogos(prev => [
  ...prev,
  { id: `svg-${Date.now()}`, filename: "logo-combined.svg", svg: combinedSvg, kind: "svg" as const }
]);

TL;DR

Model used for vector generation: OpenAI gpt-4.1 (or gpt-4.1-mini) — because we need a text model to emit valid SVG markup.

The generated icon SVG has no text (we enforce that).

You preview the SVG inline, download as .svg, and (optionally) compose a final vector SVG with your chosen font + color for the wordmark.