Claude
Skills
Sign in
Back

embeddable-widget

Included with Lifetime
$97 forever

Embeddable chat widget — iframe-based chat scoped by businessId, a widget.js loader script, appearance configuration API, and public (no-auth) customer-facing routes. Use this skill when the user says "add widget", "embeddable chat", "setup widget", "iframe chat", or "setup embeddable-widget".

Backend & APIs

What this skill does


# Embeddable Widget

Embeddable chat widget that business owners drop into their websites via a `<script>` tag. The script injects an `<iframe>` pointing to `/widget/[businessId]`, which renders a minimal chat interface scoped to that business. No authentication required for end customers — the widget is public, isolated by `businessId`.

Business owners configure widget appearance (colors, greeting, position) from an authenticated admin API.

## Prerequisites

- Next.js app with `src/` directory and App Router
- `ai-chat` skill installed (streaming chat route, message components)
- `auth` skill installed (`withAuth` at `@/lib/auth-guard`, Drizzle DB at `@/db`)
- `docker` skill installed (PostgreSQL running)
- shadcn/ui initialized

## Installation

No additional packages required. Uses existing `ai` and `@ai-sdk/react` from `ai-chat`.

## What Gets Created

```
src/
├── db/
│   └── schema/
│       └── widget.ts                          # widgetConfig table
├── app/
│   ├── api/
│   │   └── widget/
│   │       ├── route.ts                       # GET/POST — list/create widget configs (auth)
│   │       ├── [businessId]/
│   │       │   ├── route.ts                   # GET/PATCH — get/update config (auth)
│   │       │   └── chat/
│   │       │       └── route.ts               # POST — public streaming chat (no auth)
│   │       └── embed.js/
│   │           └── route.ts                   # GET — serves the widget loader script
│   └── widget/
│       └── [businessId]/
│           └── page.tsx                       # Public widget chat UI (rendered in iframe)
└── components/
    └── widget/
        ├── widget-chat.tsx                    # Minimal chat UI for the iframe
        └── widget-message.tsx                 # Compact message renderer
```

## Database

After applying this skill, push the schema:

```bash
bunx drizzle-kit push
```

## Setup Steps

### Step 1: Create `src/db/schema/widget.ts`

```typescript
import {
  pgTable,
  text,
  timestamp,
  uuid,
  jsonb,
} from "drizzle-orm/pg-core";

type WidgetAppearance = {
  primaryColor: string;
  greeting: string;
  position: "bottom-right" | "bottom-left";
  avatarUrl?: string;
  title: string;
};

export const widgetConfig = pgTable("widget_config", {
  id: uuid("id").defaultRandom().primaryKey(),
  businessId: text("business_id").notNull().unique(),
  ownerId: text("owner_id").notNull(),
  name: text("name").notNull(),
  appearance: jsonb("appearance")
    .$type<WidgetAppearance>()
    .notNull()
    .default({
      primaryColor: "#2563eb",
      greeting: "Hi! How can I help you today?",
      position: "bottom-right",
      title: "Chat with us",
    }),
  systemPrompt: text("system_prompt")
    .notNull()
    .default("You are a helpful customer support assistant. Be friendly, concise, and helpful."),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});

export type WidgetConfig = typeof widgetConfig.$inferSelect;
export type NewWidgetConfig = typeof widgetConfig.$inferInsert;
export type { WidgetAppearance };
```

### Step 2: Add export to `src/db/schema/index.ts`

Find the last `export * from` line and add:

```typescript
export * from "./widget";
```

### Step 3: Create `src/app/api/widget/route.ts`

```typescript
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-guard";
import { db } from "@/db";
import { widgetConfig } from "@/db/schema/widget";
import { eq, desc } from "drizzle-orm";
import { randomUUID } from "crypto";

/** GET /api/widget — list widget configs for the current user */
export const GET = withAuth(async (_request, { user }) => {
  const configs = await db
    .select()
    .from(widgetConfig)
    .where(eq(widgetConfig.ownerId, user.id))
    .orderBy(desc(widgetConfig.createdAt));

  return NextResponse.json(configs);
});

type CreateWidgetBody = {
  name: string;
  systemPrompt?: string;
  appearance?: {
    primaryColor?: string;
    greeting?: string;
    position?: "bottom-right" | "bottom-left";
    avatarUrl?: string;
    title?: string;
  };
};

/** POST /api/widget — create a new widget config */
export const POST = withAuth(async (request, { user }) => {
  const body: CreateWidgetBody = await request.json();

  if (!body.name || typeof body.name !== "string" || !body.name.trim()) {
    return NextResponse.json(
      { error: "Name is required" },
      { status: 400 }
    );
  }

  const businessId = randomUUID().replace(/-/g, "").slice(0, 12);

  const defaultAppearance = {
    primaryColor: "#2563eb",
    greeting: "Hi! How can I help you today?",
    position: "bottom-right" as const,
    title: "Chat with us",
  };

  const [config] = await db
    .insert(widgetConfig)
    .values({
      businessId,
      ownerId: user.id,
      name: body.name.trim(),
      systemPrompt: body.systemPrompt ?? undefined,
      appearance: body.appearance
        ? { ...defaultAppearance, ...body.appearance }
        : defaultAppearance,
    })
    .returning();

  return NextResponse.json(config, { status: 201 });
});
```

### Step 4: Create `src/app/api/widget/[businessId]/route.ts`

```typescript
import { NextRequest, NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-guard";
import { db } from "@/db";
import { widgetConfig, type WidgetAppearance } from "@/db/schema/widget";
import { eq, and } from "drizzle-orm";

type RouteContext = { params: Promise<{ businessId: string }> };

/** GET /api/widget/[businessId] — get widget config (authenticated, owner only) */
export const GET = withAuth(async (request: NextRequest, { user }) => {
  const pathParts = request.nextUrl.pathname.split("/");
  const businessId = pathParts[pathParts.length - 1];

  const configs = await db
    .select()
    .from(widgetConfig)
    .where(
      and(
        eq(widgetConfig.businessId, businessId),
        eq(widgetConfig.ownerId, user.id)
      )
    )
    .limit(1);

  if (configs.length === 0) {
    return NextResponse.json({ error: "Widget not found" }, { status: 404 });
  }

  return NextResponse.json(configs[0]);
}) as (request: NextRequest, context: RouteContext) => Promise<NextResponse>;

type UpdateWidgetBody = {
  name?: string;
  systemPrompt?: string;
  appearance?: Partial<WidgetAppearance>;
};

/** PATCH /api/widget/[businessId] — update widget config (authenticated, owner only) */
export const PATCH = withAuth(async (request: NextRequest, { user }) => {
  const pathParts = request.nextUrl.pathname.split("/");
  const businessId = pathParts[pathParts.length - 1];

  const body: UpdateWidgetBody = await request.json();

  const existing = await db
    .select()
    .from(widgetConfig)
    .where(
      and(
        eq(widgetConfig.businessId, businessId),
        eq(widgetConfig.ownerId, user.id)
      )
    )
    .limit(1);

  if (existing.length === 0) {
    return NextResponse.json({ error: "Widget not found" }, { status: 404 });
  }

  const currentAppearance = existing[0].appearance as WidgetAppearance;

  const [updated] = await db
    .update(widgetConfig)
    .set({
      ...(body.name !== undefined && { name: body.name }),
      ...(body.systemPrompt !== undefined && {
        systemPrompt: body.systemPrompt,
      }),
      ...(body.appearance !== undefined && {
        appearance: { ...currentAppearance, ...body.appearance },
      }),
      updatedAt: new Date(),
    })
    .where(
      and(
        eq(widgetConfig.businessId, businessId),
        eq(widgetConfig.ownerId, user.id)
      )
    )
    .returning();

  return NextResponse.json(updated);
}) as (request: NextRequest, context: RouteContext) => Promise<NextResponse>;
```

### Step 5: Create `src/app/api/widget/[businessId]/chat/route.ts`

This is the **public** (no auth) streaming chat endpoint scoped by businessId.

```typescript
import {
  streamText,
  convertToModelMessages,
  type UIMessage,
} from "ai";
import { getModel } from 
Files: 1
Size: 22.8 KB
Complexity: 33/100
Category: Backend & APIs

Related in Backend & APIs