Skip to main content

Install PodSaid on Next.js

Next.js is the most common React framework we see in customer embeds, and it’s also the framework that trips up the most first-time integrators. The reason is one thing: the widget uses Web Components, which touch window and customElements at register time — both of which don’t exist during server-side rendering. If you naively drop the snippet into a Server Component, Next.js will error out with ReferenceError: window is not defined at build time.

This page shows the correct pattern for both App Router (Next 13.4+, the current default) and Pages Router (legacy but still widely used).

Time: about 5 minutes.

What you need before you start:

  • A PodSaid account with a feed set up (see Quickstart).
  • Your feed-id and pk_live_ key from Console → Settings → Embed Code.
  • The domains you’ll embed on (localhost for dev, production URL, and any Vercel preview URL) added to Console → Settings → Allowed Origins. Vercel preview URLs are each their own origin — you likely want a wildcard entry there; see Allowed origins for how to request one.

The 60-second version — App Router

Create a small client component:

// components/PodSaidWidget.tsx
'use client';

import { useEffect } from 'react';

export default function PodSaidWidget() {
  useEffect(() => {
    const s = document.createElement('script');
    s.src = 'https://widget.podsaid.com/v1/widgets.iife.js';
    s.defer = true;
    document.body.appendChild(s);
    return () => { s.remove(); };
  }, []);

  return (
    // @ts-expect-error — Web Component, see "TypeScript" below
    <ask-pod-widget
      feed-id={process.env.NEXT_PUBLIC_PODSAID_FEED_ID}
      api-key={process.env.NEXT_PUBLIC_PODSAID_API_KEY}
    />
  );
}

Import it into any page or layout:

// app/sermons/page.tsx
import PodSaidWidget from '@/components/PodSaidWidget';

export default function SermonsPage() {
  return (
    <main>
      <h1>Sermons</h1>
      <PodSaidWidget />
    </main>
  );
}

Add the env vars in .env.local:

NEXT_PUBLIC_PODSAID_FEED_ID=yourfeedslug
NEXT_PUBLIC_PODSAID_API_KEY=pk_live_xxxxxxxxxxxxxxxx

If that just worked, skip to TypeScript setup and Verifying it works. If you’re on Pages Router, or you want to use next/script, keep reading.


Alternative — using next/script

If you prefer Next’s built-in script loader (better for load-order control across a large app), split into two components:

// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Script
          src="https://widget.podsaid.com/v1/widgets.iife.js"
          strategy="lazyOnload"
        />
      </body>
    </html>
  );
}
// components/PodSaidWidget.tsx
'use client';

export default function PodSaidWidget() {
  return (
    // @ts-expect-error — Web Component
    <ask-pod-widget
      feed-id={process.env.NEXT_PUBLIC_PODSAID_FEED_ID}
      api-key={process.env.NEXT_PUBLIC_PODSAID_API_KEY}
    />
  );
}

Strategy choice:

  • lazyOnload — recommended. Widget loads after your page is interactive. Users don’t wait on it.
  • afterInteractive — loads sooner but blocks nothing important. Use if you want the widget to appear a beat faster.
  • Don’t use beforeInteractive — it forces the widget into the critical rendering path with no upside.

Pages Router (Next 12 and earlier layouts)

Same idea, different files. Since Pages Router doesn’t have the 'use client' boundary, use next/dynamic with SSR disabled:

// components/PodSaidWidget.tsx
import { useEffect } from 'react';

function Widget() {
  useEffect(() => {
    const s = document.createElement('script');
    s.src = 'https://widget.podsaid.com/v1/widgets.iife.js';
    s.defer = true;
    document.body.appendChild(s);
    return () => { s.remove(); };
  }, []);

  return (
    // @ts-expect-error — Web Component
    <ask-pod-widget
      feed-id={process.env.NEXT_PUBLIC_PODSAID_FEED_ID}
      api-key={process.env.NEXT_PUBLIC_PODSAID_API_KEY}
    />
  );
}

export default Widget;
// pages/sermons.tsx
import dynamic from 'next/dynamic';

const PodSaidWidget = dynamic(() => import('@/components/PodSaidWidget'), {
  ssr: false,
});

export default function SermonsPage() {
  return (
    <main>
      <h1>Sermons</h1>
      <PodSaidWidget />
    </main>
  );
}

{ ssr: false } is the critical piece. Without it, Next tries to render <ask-pod-widget> on the server, doesn’t find customElements there, and errors.


TypeScript setup

The default TypeScript config for a Next project doesn’t know about <ask-pod-widget> — it’s a custom element, not a React component. You’ll see:

Property 'ask-pod-widget' does not exist on type 'JSX.IntrinsicElements'.

Two fixes; pick one.

Quick fix — @ts-expect-error on the tag

Shown in the examples above. Zero setup, one line per usage. Fine for one or two placements.

Proper fix — declare the element in global.d.ts

Create types/global.d.ts (or add to your existing global types):

import type { DetailedHTMLProps, HTMLAttributes } from 'react';

declare global {
  namespace JSX {
    interface IntrinsicElements {
      'ask-pod-widget': DetailedHTMLProps<
        HTMLAttributes<HTMLElement> & {
          'feed-id': string;
          'api-key': string;
          'api-url'?: string;
        },
        HTMLElement
      >;
    }
  }
}

export {};

Reference it in tsconfig.json:

{
  "include": ["next-env.d.ts", "types/**/*.ts", "**/*.ts", "**/*.tsx"]
}

Now you can drop the @ts-expect-error comments and get real autocomplete on the widget attributes.


Common Next.js gotchas

1. ReferenceError: window is not defined at build

Cause: You rendered <ask-pod-widget> from a Server Component (App Router) or without ssr: false (Pages Router).

Fix: Mark the component with 'use client' (App Router) or wrap with dynamic(…, { ssr: false }) (Pages Router). See the examples above.

2. Widget renders twice in dev, once in prod

Cause: React’s Strict Mode double-invokes effects in development to help catch bugs. Your useEffect appends the script twice — once for each invocation.

Fix: The example above already handles this with the cleanup function (return () => { s.remove(); }), but the script may re-appear briefly during the double-invocation. It’s cosmetic and gone in production. If it bothers you in dev, add a guard:

useEffect(() => {
  if (document.querySelector('script[src*="widgets.iife.js"]')) return;
  const s = document.createElement('script');
  // ...
}, []);

The next/script path (Alternative section) avoids this entirely — Next dedupes script tags itself.

3. Environment variables aren’t reaching the browser

Symptom: Widget mounts but shows a [podsaid-widget] Auth failed error immediately, and DevTools shows feed-id="" or api-key="".

Cause: You named your env vars without the NEXT_PUBLIC_ prefix. Next only exposes variables with that prefix to the browser — everything else is server-only.

Fix: Rename to NEXT_PUBLIC_PODSAID_FEED_ID and NEXT_PUBLIC_PODSAID_API_KEY in .env.local. Restart the dev server (env changes don’t hot-reload).

4. Vercel preview URLs are 403-ing

Cause: Each preview deploy gets its own subdomain like yourapp-git-branch-team.vercel.app. Every one of them is a separate origin. Your allowlist has your production domain but not these ephemeral ones.

Fix: Two options:

  • Recommended: Ask us for a wildcard origin entry like https://*.vercel.app scoped to your team’s Vercel account. Email [email protected] with your Vercel team slug — this is a one-time setup.
  • Quick: Add the preview URL manually each time you want to test. Fine for a first deploy; painful long-term.

Production URLs (your custom domain) are unaffected.

5. HMR errors about customElements.define being called twice

Symptom: After a hot reload in dev, browser console shows:

NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry':
the name "ask-pod-widget" has already been used

Cause: Hot Module Replacement re-runs your module, which re-runs the widget’s registration. The custom element registry rejects the second call.

Fix: Cosmetic — the widget still works. Ignore in dev. Production builds don’t HMR, so it never appears there. If it annoys you enough, use the next/script path (Alternative section) which loads the widget from an external script tag; that only runs once.


Verifying it works

  1. Run npm run dev and open the page in the browser.

  2. Look for the widget in the location you placed it.

  3. Open DevTools console. Look for:

    [podsaid-widget] v1.8.2 mounted (feed=yourfeedslug, api=https://api.podsaid.com)
  4. Click the widget’s launcher. Ask a test question.

If the widget appears but questions fail, jump straight to Widget errors — what each one means.


Deploying

  • Vercel — no special configuration. Just make sure your production URL is in the Allowed Origins list, and see gotcha #4 above for preview URLs.
  • Netlify — same. Add your *.netlify.app domain and any custom domain.
  • Self-hosted / Docker — nothing widget-specific. The widget is a browser-side load from widget.podsaid.com; your server never touches it.

When the widget shows “still preparing your content”

Ingest hasn’t finished for your feed yet. The widget’s mount is correct — the content just isn’t ready. Watch progress under Console → Feed → Ingest status, or wait for the “your widget is live” email. See How ingest works (and why it takes hours).


Still stuck?