DEV Community

Alex Gutscher
Alex Gutscher

Posted on

I built a testimonial wall tool and the hardest part wasn't the product — it was the embed widget

I launched KudosWall this week — a tool that lets you collect text and video testimonials via a shareable link and embed them on any site with one script tag. I want to talk about the technical decision that took the longest and caused the most pain: how to actually deliver the widget to a third-party page.

The problem with embedding on sites you don't control

When someone pastes your script tag into their WordPress site, their Webflow page, their Framer template, or their raw HTML file — you have zero control over what CSS is already on that page. A .card class on their site will clobber yours. Their * { box-sizing: content-box } will break your layout. Their p { margin: 2rem } will mess up your text spacing.

There are three ways to handle this:

Option 1: CSS namespacing

Prefix all your classes with something unique like .kw-card and hope nothing collides. Works until it doesn't. Some WordPress themes use aggressive selectors like div div div { margin: 0 } that blow straight through your namespacing.

Option 2: Shadow DOM

The widget's DOM lives inside a shadow root — a hard boundary host CSS cannot cross. Great for isolation, but forces you to bundle your entire UI library (React/Tailwind) into a single client-side JS file, which bloats the payload and hurts the initial "Time to First Testimonial."

Option 3: The "Modern" Iframe (what I went with)

I chose a server-rendered iframe approach. It provides total CSS isolation by default, but I solved the two classic iframe dealbreakers: height management and SEO.

By using a tiny loader script and a ResizeObserver inside the widget, the iframe communicates with the host page via postMessage to auto-resize instantly as content loads or carousels move. For SEO, I inject JSON-LD Structured Data directly into the embed page. This means even though the UI is in an iframe, Google still crawls the testimonials and awards the host page those coveted "Review Snippet" stars in search results.

The async script problem

Here's something that bit me early. The standard way to read a script tag's own attributes is document.currentScript:

// This breaks with async
const id = document.currentScript.getAttribute('data-id');
Enter fullscreen mode Exit fullscreen mode

document.currentScript is often null inside an async script because the script executes after the DOM has already parsed. Since performance is non-negotiable — a synchronous script blocks page rendering — I built a robust fallback:

// Robust detection for async/defer
let targetScript = document.currentScript;

if (!targetScript) {
  const scripts = document.getElementsByTagName('script');
  for (let i = scripts.length - 1; i >= 0; i--) {
    const s = scripts[i];
    if (s.getAttribute('data-id') && s.src.indexOf('widget.js') !== -1) {
      targetScript = s;
      break;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This ensures the widget always finds its configuration ID regardless of how the host site handles script loading.

Keeping it small

The widget.js loader is under 3KB. Its only job is to detect the data-id, inject the iframe, and listen for resize events.

The heavy lifting — rendering the UI, handling dark/light mode, and animations — is done server-side in Next.js. This keeps the client-side JS footprint on the customer's site near zero while allowing me to use the full power of React and Tailwind CSS for the widget itself.

The data layer

The widget content is rendered via Next.js Server Components. When the iframe hits /embed/:id, we fetch the approved testimonials directly from the database:

// Fetching via Drizzle ORM in a Server Component
const testimonialsList = await db.query.testimonial.findMany({
  where: and(
    inArray(testimonial.projectId, projectIds),
    eq(testimonial.status, 'approved')
  ),
  orderBy: desc(testimonial.createdAt),
  limit: settings.maxItems || 20,
});
Enter fullscreen mode Exit fullscreen mode

Because it's server-rendered, the first paint is nearly instantaneous. No loading spinner inside the widget while waiting for a client-side fetch.

The rest of the stack

  • Frontend: Next.js 16 (App Router), Tailwind CSS v4
  • Database: PostgreSQL (Neon) with Drizzle ORM
  • Auth: Better-Auth (handles multi-tenant session isolation)
  • Payments: Stripe Billing + Webhooks
  • Email: Resend
  • Storage: Cloudflare R2 (for high-bandwidth video testimonials)
  • Deployment: Vercel

Drizzle's relational queries handle multi-tenant data isolation. Each workspace's data is strictly separated at the query level.

What's live

KudosWall is live at kudoswall.org. Free tier: 5 testimonials, one active widget, no time limit. Pro: $29/mo for unlimited testimonials, all layouts, and removal of the "Powered by" branding.

The embed snippet is as simple as it gets:

<script
  src="https://kudoswall.org/widget.js"
  data-id="cfg_abc123"
  async
></script>
Enter fullscreen mode Exit fullscreen mode

That's the whole integration. Works on WordPress, Webflow, Framer, and Shopify without any CSS collisions or layout shifts.

Happy to answer questions about the postMessage resize logic, the Drizzle + Neon setup, or why I moved away from Supabase to the Drizzle/Better-Auth stack.

Top comments (0)