For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
Log inSign up
Support
GuidesReferenceClick-to-Call
GuidesReferenceClick-to-Call
  • Getting Started
    • Overview
    • Authentication
    • RxJS Primer
    • Migrate from v3
  • Web Components
    • Overview
    • Click-to-Call
    • Theming
    • Customization
  • Build Voice & Video Apps
    • Overview
    • Outbound Calls
    • Inbound Calls
    • Device Management
    • Screen Sharing
    • Call Controls
    • Layouts
    • Messaging & Chat
  • Manage Resources
    • Overview
    • Users
    • Address Book
    • Client Preferences
    • Capabilities
  • Deploy
    • Overview
    • Framework Integration
    • SSR & Next.js
    • Troubleshooting
LogoLogoSignalWire Docs
Log inSign up
Support
On this page
  • Next.js App Router
  • Client components
  • When dynamic() is needed
  • Token route handler
  • Pages Router
  • Hydration caveats
  • Environment variables
Deploy

SSR & Next.js

|View as Markdown|Open in Claude|
Was this page helpful?
Edit this page
Previous

Troubleshooting & FAQ

Next
Built with

@signalwire/js and @signalwire/web-components are browser-only. They depend on WebSocket, RTCPeerConnection, navigator.mediaDevices, and customElements — APIs that don’t exist in Node.js. Importing either package at the top of a server-rendered module will crash the server during build or during SSR.

Two boundaries to hold across any server-rendered framework: load the SDK only on the client, and mint tokens only on the server. The patterns below are Next.js–specific, but the same shape applies in Nuxt, SvelteKit, Remix, Astro, or Gatsby — wrap the SDK in that framework’s client-only escape hatch, and put token minting behind a server route.

Next.js App Router

Client components

Wrap any code that touches the SDK in a "use client" component. The import won’t execute on the server.

1// app/_components/dialer.tsx
2"use client";
3
4import { useEffect, useState } from "react";
5import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
6
7export function Dialer({ token }: { token: string }) {
8 const [client, setClient] = useState<SignalWire | null>(null);
9
10 useEffect(() => {
11 const c = new SignalWire(new StaticCredentialProvider({ token }));
12 setClient(c);
13 return () => c.disconnect();
14 }, [token]);
15
16 // ...
17}

A "use client" file’s transitive imports are fine — they’re bundled for the browser, not Node. You don’t need dynamic() for ordinary SDK use.

When dynamic() is needed

next/dynamic with ssr: false is only required when an import has side effects that run at module evaluation time (custom element registration, top-level new SignalWire(...), etc.). The web components package registers customElements.define on import — so in the App Router, import the components from inside an effect or use next/dynamic:

1// app/_components/widget-mount.tsx
2"use client";
3
4import dynamic from "next/dynamic";
5import { useEffect } from "react";
6
7const RegisterWebComponents = dynamic(
8 () => import("@signalwire/web-components").then(() => () => null),
9 { ssr: false }
10);
11
12export function Widget({ token }: { token: string }) {
13 return (
14 <>
15 <RegisterWebComponents />
16 <sw-call-widget token={token} destination="/public/sales" />
17 </>
18 );
19}

For the embed bundle (signalwire-web-components-embed.iife.js) loaded via <script>, use next/script with strategy="lazyOnload" or "afterInteractive":

1// app/layout.tsx
2import Script from "next/script";
3
4export default function RootLayout({ children }: { children: React.ReactNode }) {
5 return (
6 <html>
7 <body>
8 {children}
9 <Script
10 src="https://unpkg.com/@signalwire/web-components/dist/embed/signalwire-web-components-embed.iife.js"
11 strategy="afterInteractive"
12 />
13 </body>
14 </html>
15 );
16}

Token route handler

Mint tokens in a route handler. This is the only place your SignalWire project credentials are allowed to live.

1// app/api/sw-token/route.ts
2import { NextResponse } from "next/server";
3
4export const dynamic = "force-dynamic"; // never cache the token
5
6export async function POST(req: Request) {
7 // 1. Authenticate the caller. Read your session cookie / NextAuth
8 // session here — whatever your app uses.
9 const user = await getUserFromRequest(req);
10 if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
11
12 // 2. Mint a Subscriber Access Token (SAT) via the SignalWire REST API.
13 const auth = Buffer.from(
14 `${process.env.SIGNALWIRE_PROJECT_ID}:${process.env.SIGNALWIRE_TOKEN}`
15 ).toString("base64");
16
17 const res = await fetch(
18 `https://${process.env.SIGNALWIRE_SPACE}/api/fabric/subscribers/tokens`,
19 {
20 method: "POST",
21 headers: {
22 Authorization: `Basic ${auth}`,
23 "Content-Type": "application/json",
24 },
25 body: JSON.stringify({ reference: user.id }),
26 }
27 );
28
29 if (!res.ok) {
30 return NextResponse.json({ error: "token mint failed" }, { status: 502 });
31 }
32
33 const { token } = await res.json();
34 return NextResponse.json({ token });
35}

On the client, wrap the route in a CredentialProvider so the SDK can re-fetch a fresh SAT when the current one nears expiry. expiry_at is a Date.now()-style millisecond timestamp.

1// app/_lib/sw-credentials.ts
2"use client";
3
4import type { CredentialProvider } from "@signalwire/js";
5
6export class FetchedCredentialProvider implements CredentialProvider {
7 async authenticate() {
8 const res = await fetch("/api/sw-token", { method: "POST" });
9 if (!res.ok) throw new Error("token fetch failed");
10 const { token } = await res.json();
11 // Default SAT lifetime is 2h. If you override `expire_at` when
12 // minting, compute from that value instead.
13 return { token, expiry_at: Date.now() + 2 * 60 * 60 * 1000 };
14 }
15 async refresh() {
16 return this.authenticate();
17 }
18}

See Authentication › Refresh strategies for refresh-token-based flows that avoid re-authenticating the user on every rollover.

Pages Router

For a pages/-based Next.js app, the only difference is that there’s no "use client" directive. Use next/dynamic({ ssr: false }) for any component that imports the SDK:

1// pages/index.tsx
2import dynamic from "next/dynamic";
3
4const Dialer = dynamic(() => import("@/components/dialer"), { ssr: false });
5
6export default function Home() {
7 return <Dialer />;
8}

Token endpoints live under pages/api/sw-token.ts and use the same REST flow as the App Router example.

Hydration caveats

  • Don’t render call state from the server. Anything driven by an observable belongs in a useEffect / onMount / <ClientOnly> — not in the initial server render. Otherwise hydration will mismatch (the server rendered "idle", the client mounts with "connected").
  • Don’t read window in module scope. Even inside a "use client" file, the module body runs once during client hydration. Wrap any window. / document. access in an effect.
  • <video> srcObject won’t survive serialization. Bind it from an effect after the stream observable emits — never from a prop on the first render.

Environment variables

VariableLives inWhy
SIGNALWIRE_PROJECT_IDServer onlyAPI credential. Never expose to the browser.
SIGNALWIRE_TOKENServer onlyAPI credential. Never expose to the browser.
SIGNALWIRE_SPACEServer onlyHostname for REST minting.
NEXT_PUBLIC_SW_HOSTPublic (client)Optional. The WebSocket host the SDK connects to.

In Next.js, NEXT_PUBLIC_* is the only prefix that gets inlined into the client bundle. Project ID and auth token must stay on the server side of that boundary, always.

See Authentication for the full token flow.