Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
"widget/widget-api-reference",
"widget/monetize",
"widget/compatibility",
"widget/ssr",
{
"group": "Examples",
"pages": [
Expand Down
52 changes: 44 additions & 8 deletions widget/compatibility.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
title: "Velora Widget compatibility"
sidebarTitle: "Compatibility"
description: "Tested environments, peer-dependency ranges, SSR caveats, and performance tips for @velora-dex/widget."
keywords: ["widget","compatibility","ssr","nextjs","vite","tailwind"]
description: "Tested environments, peer-dependency ranges, server-side rendering support, and performance tips for @velora-dex/widget."
keywords: ["widget","compatibility","ssr","server-side rendering","nextjs","astro","vite","tailwind"]
---

## Peer dependencies
Expand All @@ -20,24 +20,58 @@ keywords: ["widget","compatibility","ssr","nextjs","vite","tailwind"]
| Framework | Status | Notes |
|---|---|---|
| Vite + React | ✅ Works out of the box | See [examples/react](/widget/examples/react). |
| Next.js (app router) | ✅ Works with client-only mount | Use `dynamic(... { ssr: false })`. See [examples/nextjs](/widget/examples/nextjs). |
| Next.js (pages router) | ✅ Works with `dynamic` | Same dynamic-import pattern as app router. |
| Next.js (app router) | ✅ Works, server-rendered | Render it from a `"use client"` module. See [examples/nextjs](/widget/examples/nextjs). |
| Next.js (pages router) | ✅ Works | Client-only via `dynamic(... { ssr: false })`, or server-rendered with `ssrState` from `getServerSideProps`. |
| Astro | ✅ Works, server-rendered | Needs a server adapter and `prerender = false` on the route. |
| TanStack Start | ✅ Works, server-rendered | Resolve in a route loader, including React Server Components. |
| Create React App | ✅ Works | No special setup. |

## SSR caveat
## Server-side rendering

The widget reads `window`, `localStorage`, and `prefers-color-scheme` on mount. It does **not** support server-side rendering. In Next.js (or any SSR/SSG framework), gate the import:
The widget server-renders. Render `<Widget />` from a server-rendered route and the HTML contains a working widget, painted with its **default state**: the default pair for the configured chain, empty amounts, the default trade mode. The client resolves the tokens your `input` names shortly after mount, which shows up as a brief flash.

To remove that flash, resolve the tokens on the server with the `@velora-dex/widget/ssr` entry and pass the result back in through `ssrState`:

```tsx
// server
import { FileCache, resolveWidgetInitState } from "@velora-dex/widget/ssr";

const { initState } = await resolveWidgetInitState(
{ config: widgetConfig, input },
{ cache: new FileCache() }
);

// client, same config object, same initState object
<Widget config={widgetConfig} input={input} ssrState={{ initState }} />;
```

`resolveWidgetSSRQueries` takes it one step further and prefetches prices and bridge routes, so rates are on the page at first paint too.

Keep the `/ssr` entry out of browser bundles: `FileCache` imports `node:fs`, so value imports belong in server-only modules. Type imports are erased at compile time and are safe anywhere.

Call `resolveWidgetInitState` with the same `config` object you render `<Widget />` with. It decides which token lists and trade modes are enabled, and the two renders disagree if it drifts.

The route has to render per request, since the resolved state depends on the request. A statically prerendered page cannot carry it.

[Server-side rendering](/widget/ssr) has the rest: prefetching rates with `resolveWidgetSSRQueries`, picking a cache backend for your runtime, acting on a token that could not be resolved, and deferring the widget behind a placeholder.

### Client-only mount

Still supported, and the right answer when there is no server at request time (a static export) or you want the widget's JavaScript off the critical path:

```tsx
// Next.js app router
import dynamic from "next/dynamic";
import { WidgetSkeleton } from "@velora-dex/widget";

const Widget = dynamic(
() => import("@velora-dex/widget").then((m) => ({ default: m.Widget })),
{ ssr: false }
{ ssr: false, loading: () => <WidgetSkeleton theme="light" /> }
);
```

The initial HTML then has no widget in it, so anything that doesn't run JavaScript sees only the placeholder.

## Tailwind v4

The widget ships compiled Tailwind v4 styles scoped under the `.velora-widget` class. You don't need Tailwind in your host app; the bundled styles are self-contained.
Expand Down Expand Up @@ -134,12 +168,14 @@ The widget is large: it ships wallet connectors, EVM tooling, and the full tradi

## Known caveats

- Server-side rendering is not supported (see SSR caveat above).
- Strict-mode double-mount in dev triggers a brief double-fetch of price quotes, harmless and confined to development.
- Sandboxed iframes without `allow-popups` and `allow-storage-access-by-user-activation` will break wallet-connection popups.
- Don't server-render two widgets with different `input` on one page. Each resolves its own state on the server, but they share one store in the browser, so the second adopts the first's trade and React reports a hydration mismatch. Two widgets showing the same trade are fine.
- The `FileCache` disk tier degrades quietly on a read-only filesystem: every write is best-effort, so you get no caching and no error. Serverless targets usually want `Cache`, or a backend of your own.

## Related pages

- [Install](/widget/install) — install + render.
- [examples/react](/widget/examples/react), [examples/nextjs](/widget/examples/nextjs) — concrete setups.
- [Customize](/widget/customize) — CSS scoping details.
- [Server-side rendering](/widget/ssr) — resolving state on the server, caching, deferred rendering.
167 changes: 143 additions & 24 deletions widget/examples/nextjs.mdx
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
---
title: "Velora Widget Next.js example"
sidebarTitle: "Next.js"
description: "End-to-end widget integration for the Next.js app router, with a client-only mount and a /swap route."
keywords: ["widget","nextjs","example","app-router"]
description: "End-to-end widget integration for the Next.js app router: a server-rendered widget at /swap, and how to resolve its tokens on the server."
keywords: ["widget","nextjs","example","app-router","ssr","server-side rendering"]
---

A Next.js 14+ app-router integration that mounts the Velora Widget at `/swap`. The widget reads `window` and `localStorage` on mount, so it must be client-only. We use `next/dynamic` with `ssr: false`.
A Next.js 14+ app-router integration that mounts the Velora Widget at `/swap`. The widget server-renders, so no `dynamic(... { ssr: false })` wrapper is needed: mark the file `"use client"` and render it. Next renders it to HTML on the request and hydrates it in the browser.

Out of the box that first paint shows the widget's default state. To make it show the tokens the request asks for, resolve them on the server first — see [First paint with the right tokens](#first-paint-with-the-right-tokens).

## File tree

```text
my-app/
├─ package.json
├─ next.config.js
└─ src/app/
├─ layout.tsx
├─ page.tsx
└─ swap/
├─ page.tsx
└─ widget.tsx
└─ src/
├─ app/
│ ├─ layout.tsx
│ ├─ page.tsx
│ └─ swap/
│ ├─ page.tsx
│ └─ widget.tsx
└─ lib/ # added by "First paint with the right tokens"
├─ widget-config.ts
└─ widget-ssr.ts
```

## Install
Expand Down Expand Up @@ -59,17 +65,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })

## `src/app/swap/widget.tsx`

The widget itself, wrapped in `dynamic(... { ssr: false })`.
The widget itself. `"use client"` because it uses hooks and browser APIs once it mounts; it is still rendered to HTML on the server for the initial request.

```tsx
"use client";

import dynamic from "next/dynamic";

const Widget = dynamic(
() => import("@velora-dex/widget").then((m) => ({ default: m.Widget })),
{ ssr: false, loading: () => <div>Loading widget…</div> }
);
import { Widget } from "@velora-dex/widget";

export default function ClientWidget() {
return (
Expand Down Expand Up @@ -115,26 +116,143 @@ pnpm dev

Visit [http://localhost:3000/swap](http://localhost:3000/swap). You should see the widget render with **Connect Wallet** in the header.

## Why `ssr: false`?
## First paint with the right tokens

The widget reads `window`, `localStorage`, and `prefers-color-scheme` on mount. None of those exist during server rendering. `dynamic(... { ssr: false })` defers the import to the browser, so the widget only ever runs client-side.
A plain server render paints the widget's default token pair, then swaps in the pair your `input` names once token lists load in the browser. That flash is what the server-only `@velora-dex/widget/ssr` entry removes: resolve the tokens on the server, pass them to the widget as `ssrState`, and hand the same object to the client so both renders agree.

If you forget `ssr: false`, you'll see a hydration-mismatch error or a `window is not defined` exception.
Four files. The first matters more than it looks: `resolveWidgetInitState` must run with the same `config` you render with, so export it once.

## With dApp-mode wallet
```ts src/lib/widget-config.ts
import type { WidgetProps } from "@velora-dex/widget";

If your app already manages the wallet (wagmi, RainbowKit, or any other host-side wallet library), switch to dApp mode and pass the provider:
export const widgetConfig = {
theme: "light",
partnerConfig: { partner: "my-app-name" },
} satisfies WidgetProps["config"];
```

```ts src/lib/widget-ssr.ts
import { FileCache, Cache, resolveWidgetInitState } from "@velora-dex/widget/ssr";
import type { WidgetProps } from "@velora-dex/widget";
import { widgetConfig } from "./widget-config";

// One cache per server process. FileCache adds a disk tier, so downloaded
// token lists survive restarts; use Cache where there is no writable disk if necessary.
const tokenLists = new FileCache();

export async function resolveWidgetState(input: WidgetProps["input"]) {
// `confirmed` reports which sides resolved to the token you asked for and
// which fell back to a widget default.
const { initState, confirmed } = await resolveWidgetInitState(
{ config: widgetConfig, input },
{ cache: tokenLists }
);

return { initState, confirmed };
}
```

```tsx src/app/swap/page.tsx
import ClientWidget from "./widget";
import { resolveWidgetState } from "@/lib/widget-ssr";

// The resolved state depends on the request, so this route must render per
// request rather than be prerendered at build time.
export const dynamic = "force-dynamic";

export default async function SwapPage({
searchParams,
}: {
searchParams: Promise<{ from?: string; to?: string }>;
}) {
const { from, to } = await searchParams;

const input = {
srcChainId: 1,
tokenFromAddress: from,
tokenToAddress: to,
};

const { initState } = await resolveWidgetState(input);

return (
<main style={{ display: "flex", justifyContent: "center", padding: "2rem" }}>
<ClientWidget input={input} ssrState={{ initState }} />
</main>
);
}
```

```tsx src/app/swap/widget.tsx
"use client";

import { Widget, type WidgetProps } from "@velora-dex/widget";
import type { WidgetSSRState } from "@velora-dex/widget/ssr";
import { widgetConfig } from "@/lib/widget-config";

export default function ClientWidget({
input,
ssrState,
}: {
input: WidgetProps["input"];
ssrState: WidgetSSRState;
}) {
return <Widget config={widgetConfig} input={input} ssrState={ssrState} />;
}
```

<Warning>
Import **values** from `@velora-dex/widget/ssr` in server files only. `FileCache` pulls in `node:fs`, and a `"use client"` module that imports it breaks the client build. Type imports (`import type { WidgetSSRState }`) are erased at compile time and are safe anywhere.
</Warning>

Tokens are step one. `resolveWidgetSSRQueries` goes further and prefetches prices, rates and bridge routes into a dehydrated query cache, so the first paint carries live numbers too:

```ts
const { queryState } = await resolveWidgetSSRQueries(
{ config: widgetConfig, input },
initState,
{ cache: tokenLists, timeoutMs: 1500 }
);
// <ClientWidget input={input} ssrState={{ initState, queryState }} />
```

Budget for it: that serialized cache is usually the largest thing on the page, several hundred KB on a typical trade route.

<Note>
[Server-side rendering](/widget/ssr) is the framework-agnostic version of this, and covers what comes next: prefetching rates, cache backends for serverless runtimes, telling a token that isn't listed from a list that failed to load, and deferring the widget behind a placeholder.
</Note>

## Do you still need `ssr: false`?

Not for correctness. The widget renders on the server, and `ssrState` is what keeps the server and client renders in agreement.

You would still reach for it when there is no server at request time, since a statically exported page cannot resolve anything per request. Some teams also use it to keep the widget's JavaScript off the critical path, though in the App Router a Suspense boundary usually serves that better: the widget arrives later in the same response instead of waiting for a second round trip.

Either way the initial HTML has no widget in it, so crawlers and link unfurlers that don't run JavaScript see only the placeholder, and the visitor watches the default tokens resolve after mount.

```tsx
"use client";

import dynamic from "next/dynamic";
import { useConnection, useWalletClient } from "wagmi";
import { WidgetSkeleton } from "@velora-dex/widget";

const Widget = dynamic(
() => import("@velora-dex/widget").then((m) => ({ default: m.Widget })),
{ ssr: false }
{ ssr: false, loading: () => <WidgetSkeleton theme="light" /> }
);
```

`WidgetSkeleton` is a static placeholder shipped from the main entry. It reserves the widget's shape, so the swap-in shifts nothing on the page.

## With dApp-mode wallet

If your app already manages the wallet (wagmi, RainbowKit, or any other host-side wallet library), switch to dApp mode and pass the provider:

```tsx
"use client";

import { Widget } from "@velora-dex/widget";
import { useConnection, useWalletClient } from "wagmi";

export default function ClientWidget() {
const { connector } = useConnection();
Expand Down Expand Up @@ -167,5 +285,6 @@ See [Wallet management](/widget/wallet-management) for the full pattern.

- [Install](/widget/install) — package install and basic setup.
- [Wallet management](/widget/wallet-management) — standalone vs dApp mode.
- [Compatibility](/widget/compatibility) — full SSR notes.
- [Server-side rendering](/widget/ssr) — the full reference for `@velora-dex/widget/ssr`.
- [Compatibility](/widget/compatibility) — peer dependencies, tested frameworks, known caveats.
- [React example](/widget/examples/react) — same widget on a Vite stack.
Loading