DEV Community

haowen sun
haowen sun

Posted on

Building a Privacy-First Comment Image Generator with Next.js, SVG, and Canvas

Creators often need a comment visual as part of a video edit: a compact reply sticker for a hook, or a complete vertical thread for a mockup. The obvious implementation is to render some HTML and take a screenshot. I chose a different architecture for Comment Studio, a browser-based TikTok comment generator: React owns a structured scene, SVG provides the live preview, and Canvas performs the final PNG export.

This post explains why that pipeline works well, where it can fail, and how keeping the entire workflow in the browser improves both privacy and product simplicity.

Start with a scene model, not pixels

A comment image looks simple until every visual state becomes editable. Each row can have a username, avatar, message, timestamp, like count, verified state, pinned label, creator label, viewer-liked heart, liked-by-creator label, hidden reply count, and a nested creator reply.

Treating the preview as the source of truth quickly becomes fragile. Instead, I keep one typed scene object in React state:

type PreviewMode = "reply-sticker" | "comment-section";
type PreviewTheme = "transparent" | "light" | "dark";

type CommentItem = {
  id: string;
  identity: {
    username: string;
    avatarDataUrl: string;
    verified: boolean;
    isCreator: boolean;
  };
  comment: {
    message: string;
    time: string;
    likes: string;
    pinned: boolean;
    viewerLiked: boolean;
    likedByCreator: boolean;
  };
  reply?: {
    username: string;
    message: string;
    time: string;
    likes: string;
  };
};

type CommentScene = {
  mode: PreviewMode;
  theme: PreviewTheme;
  selectedId: string;
  comments: CommentItem[];
};
Enter fullscreen mode Exit fullscreen mode

The production model has a few more details, but this shows the important boundary: the editor changes data, and the renderer turns that data into pixels. The same scene can drive a compact sticker or a 1080 × 1920 comment section without maintaining two unrelated editors.

One state object also makes actions such as duplicate, remove, select, and apply preset predictable. A preset is simply another valid scene rather than a bundle of imperative DOM changes.

Why SVG is the useful middle layer

The preview needs to solve two different jobs:

  1. It must remain interactive in the browser.
  2. It must export at a deterministic resolution.

SVG fits both. Text, avatars, icons, reply connectors, labels, and backgrounds remain regular inspectable elements. A comment group can receive a click handler for selection, while the root viewBox defines the coordinate system used during export.

This avoids coupling export size to the CSS size of the editor. The preview might be 360 pixels wide on a phone, but the scene can still describe a 1080 × 1920 output. For a reply sticker, the same vector scene can be exported at 1×, 2×, or 3× without redrawing every element manually on Canvas.

There is also a practical benefit: SVG makes transparent output straightforward. The clear sticker theme simply omits an opaque background before rasterization.

The SVG-to-PNG pipeline

The export path has five steps:

  1. Read the base dimensions from the SVG viewBox.
  2. Clone the SVG so export cleanup does not mutate the live preview.
  3. Remove controls or selection hints marked as preview-only.
  4. Serialize and decode the cloned SVG as an image.
  5. Draw it to a scaled Canvas and encode a PNG blob.

Here is a reduced version of the renderer:

export async function renderSvgToPng(
  svg: SVGSVGElement,
  scale: number,
) {
  const { width: baseWidth, height: baseHeight } = svg.viewBox.baseVal;

  if (!baseWidth || !baseHeight) {
    throw new Error("The preview has no export dimensions.");
  }

  const clone = svg.cloneNode(true) as SVGSVGElement;
  clone
    .querySelectorAll("[data-preview-only]")
    .forEach((element) => element.remove());

  clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
  clone.setAttribute("width", String(baseWidth));
  clone.setAttribute("height", String(baseHeight));

  const source = new XMLSerializer().serializeToString(clone);
  const svgBlob = new Blob([source], {
    type: "image/svg+xml;charset=utf-8",
  });
  const objectUrl = URL.createObjectURL(svgBlob);

  try {
    const image = await loadImage(objectUrl);
    const width = Math.round(baseWidth * scale);
    const height = Math.round(baseHeight * scale);
    const canvas = document.createElement("canvas");

    canvas.width = width;
    canvas.height = height;

    const context = canvas.getContext("2d");
    if (!context) throw new Error("Canvas is unavailable.");

    context.imageSmoothingEnabled = true;
    context.imageSmoothingQuality = "high";
    context.drawImage(image, 0, 0, width, height);

    const png = await new Promise<Blob>((resolve, reject) => {
      canvas.toBlob(
        (result) => result ? resolve(result) : reject(new Error("PNG encoding failed.")),
        "image/png",
      );
    });

    return { blob: png, width, height };
  } finally {
    URL.revokeObjectURL(objectUrl);
  }
}
Enter fullscreen mode Exit fullscreen mode

The production implementation revokes the temporary SVG URL as soon as the image finishes decoding. The downloadable PNG receives its own object URL, which is also revoked shortly after the temporary anchor is clicked. Explicit cleanup matters because repeated exports can otherwise leave unnecessary blobs alive for the rest of the session.

Keep uploaded avatars local

An avatar does not need a server upload just to appear in a generated image. The editor accepts PNG, JPEG, and WebP files, rejects unsupported types and files over 5 MB, then reads an approved file with FileReader:

const reader = new FileReader();

reader.onload = () => {
  updateIdentity({ avatarDataUrl: String(reader.result ?? "") });
};

reader.onerror = () => {
  setStatus("The avatar could not be read.");
};

reader.readAsDataURL(file);
Enter fullscreen mode Exit fullscreen mode

The data URL becomes the SVG image source and is embedded in the exported PNG. It is never necessary to send the avatar, filename, username, or comment copy to an application server. Refreshing or closing the page clears the current scene.

This is more than a privacy feature. It removes an entire backend workflow: no upload endpoint, object storage, cleanup job, account system, or temporary-file retention policy is required.

Analytics should describe the workflow, not the content

A local editor can still answer product questions such as:

  • Which output mode is used more often?
  • Do people export at 1×, 2×, or 3×?
  • Which theme is selected?
  • Did an export succeed or fail?

It does not need the content of the scene to answer them. I use an event allowlist so each event accepts only coarse parameters such as mode, scale, theme, viewport group, broad browser group, duration bucket, or file-size bucket.

Free-form comment text, usernames, avatar data, filenames, and full user-agent strings are excluded. The architectural rule is simple: analytics can record what part of the workflow ran, never what the user created.

Where Next.js fits

The generator workspace is a client component because it depends on React state, file input, SVG references, Canvas, and download APIs. The rest of the site does not need to inherit that constraint.

Next.js App Router still handles the public page shell, metadata, structured data, blog routes, legal pages, and optimized static assets. Keeping the browser editor behind a clear client boundary lets the content pages retain a simpler rendering model while the interactive workspace uses browser-only APIs.

This separation is especially useful for a tool whose landing page must explain what it does before JavaScript interaction begins.

Lessons from the build

The main lessons are reusable beyond comment graphics:

  1. Model editable visuals as structured data before thinking about export.
  2. Use SVG when the preview must be both interactive and resolution-independent.
  3. Clone and sanitize the export tree instead of modifying the live preview.
  4. Revoke every object URL you create.
  5. Keep private user content out of analytics by enforcing an allowlist in code.
  6. Ask whether a local browser API can replace a backend workflow.

The result is a small architecture with a useful property: the same scene the user edits is the scene the browser exports. There is no remote rendering queue and no hidden copy of the conversation on a server.

If you want to inspect the finished workflow from a user’s perspective, try the live generator linked at the beginning of the article. I would be interested to hear how you handle SVG export, font consistency, and local-file privacy in your own browser tools.

Top comments (0)