diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts
index a2d339a04..c5d17b247 100644
--- a/apps/dashboard/src/routeTree.gen.ts
+++ b/apps/dashboard/src/routeTree.gen.ts
@@ -9,57 +9,52 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
-import { Route as LoginRouteImport } from './routes/login'
-import { Route as AppRouteImport } from './routes/_app'
import { Route as IndexRouteImport } from './routes/index'
-import { Route as MoneriumCallbackRouteImport } from './routes/monerium.callback'
-import { Route as InviteTokenRouteImport } from './routes/invite.$token'
-import { Route as AppTransferRouteImport } from './routes/_app/transfer'
-import { Route as AppTransactionsRouteImport } from './routes/_app/transactions'
-import { Route as AppSettingsRouteImport } from './routes/_app/settings'
-import { Route as AppRecipientsRouteImport } from './routes/_app/recipients'
-import { Route as AppQuoteRouteImport } from './routes/_app/quote'
-import { Route as AppOverviewRouteImport } from './routes/_app/overview'
-import { Route as AppLimitsRouteImport } from './routes/_app/limits'
+import { Route as AppRouteImport } from './routes/_app'
+import { Route as LoginRouteImport } from './routes/login'
import { Route as AppApiKeysRouteImport } from './routes/_app/api-keys'
+import { Route as AppLimitsRouteImport } from './routes/_app/limits'
+import { Route as AppOverviewRouteImport } from './routes/_app/overview'
+import { Route as AppQuoteRouteImport } from './routes/_app/quote'
+import { Route as AppRecipientsRouteImport } from './routes/_app/recipients'
+import { Route as AppSettingsRouteImport } from './routes/_app/settings'
+import { Route as AppTransactionsRouteImport } from './routes/_app/transactions'
+import { Route as AppTransferRouteImport } from './routes/_app/transfer'
+import { Route as InviteTokenRouteImport } from './routes/invite.$token'
+import { Route as MoneriumCallbackRouteImport } from './routes/monerium.callback'
-const LoginRoute = LoginRouteImport.update({
- id: '/login',
- path: '/login',
- getParentRoute: () => rootRouteImport,
-} as any)
-const AppRoute = AppRouteImport.update({
- id: '/_app',
- getParentRoute: () => rootRouteImport,
-} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
-const MoneriumCallbackRoute = MoneriumCallbackRouteImport.update({
- id: '/monerium/callback',
- path: '/monerium/callback',
+const AppRoute = AppRouteImport.update({
+ id: '/_app',
getParentRoute: () => rootRouteImport,
} as any)
-const InviteTokenRoute = InviteTokenRouteImport.update({
- id: '/invite/$token',
- path: '/invite/$token',
+const LoginRoute = LoginRouteImport.update({
+ id: '/login',
+ path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
-const AppTransferRoute = AppTransferRouteImport.update({
- id: '/transfer',
- path: '/transfer',
+const AppApiKeysRoute = AppApiKeysRouteImport.update({
+ id: '/api-keys',
+ path: '/api-keys',
getParentRoute: () => AppRoute,
} as any)
-const AppTransactionsRoute = AppTransactionsRouteImport.update({
- id: '/transactions',
- path: '/transactions',
+const AppLimitsRoute = AppLimitsRouteImport.update({
+ id: '/limits',
+ path: '/limits',
getParentRoute: () => AppRoute,
} as any)
-const AppSettingsRoute = AppSettingsRouteImport.update({
- id: '/settings',
- path: '/settings',
+const AppOverviewRoute = AppOverviewRouteImport.update({
+ id: '/overview',
+ path: '/overview',
+ getParentRoute: () => AppRoute,
+} as any)
+const AppQuoteRoute = AppQuoteRouteImport.update({
+ id: '/quote',
+ path: '/quote',
getParentRoute: () => AppRoute,
} as any)
const AppRecipientsRoute = AppRecipientsRouteImport.update({
@@ -67,25 +62,30 @@ const AppRecipientsRoute = AppRecipientsRouteImport.update({
path: '/recipients',
getParentRoute: () => AppRoute,
} as any)
-const AppQuoteRoute = AppQuoteRouteImport.update({
- id: '/quote',
- path: '/quote',
+const AppSettingsRoute = AppSettingsRouteImport.update({
+ id: '/settings',
+ path: '/settings',
getParentRoute: () => AppRoute,
} as any)
-const AppOverviewRoute = AppOverviewRouteImport.update({
- id: '/overview',
- path: '/overview',
+const AppTransactionsRoute = AppTransactionsRouteImport.update({
+ id: '/transactions',
+ path: '/transactions',
getParentRoute: () => AppRoute,
} as any)
-const AppLimitsRoute = AppLimitsRouteImport.update({
- id: '/limits',
- path: '/limits',
+const AppTransferRoute = AppTransferRouteImport.update({
+ id: '/transfer',
+ path: '/transfer',
getParentRoute: () => AppRoute,
} as any)
-const AppApiKeysRoute = AppApiKeysRouteImport.update({
- id: '/api-keys',
- path: '/api-keys',
- getParentRoute: () => AppRoute,
+const InviteTokenRoute = InviteTokenRouteImport.update({
+ id: '/invite/$token',
+ path: '/invite/$token',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const MoneriumCallbackRoute = MoneriumCallbackRouteImport.update({
+ id: '/monerium/callback',
+ path: '/monerium/callback',
+ getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
@@ -188,11 +188,11 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
- '/login': {
- id: '/login'
- path: '/login'
- fullPath: '/login'
- preLoaderRoute: typeof LoginRouteImport
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_app': {
@@ -202,46 +202,39 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppRouteImport
parentRoute: typeof rootRouteImport
}
- '/': {
- id: '/'
- path: '/'
- fullPath: '/'
- preLoaderRoute: typeof IndexRouteImport
- parentRoute: typeof rootRouteImport
- }
- '/monerium/callback': {
- id: '/monerium/callback'
- path: '/monerium/callback'
- fullPath: '/monerium/callback'
- preLoaderRoute: typeof MoneriumCallbackRouteImport
+ '/login': {
+ id: '/login'
+ path: '/login'
+ fullPath: '/login'
+ preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
- '/invite/$token': {
- id: '/invite/$token'
- path: '/invite/$token'
- fullPath: '/invite/$token'
- preLoaderRoute: typeof InviteTokenRouteImport
- parentRoute: typeof rootRouteImport
+ '/_app/api-keys': {
+ id: '/_app/api-keys'
+ path: '/api-keys'
+ fullPath: '/api-keys'
+ preLoaderRoute: typeof AppApiKeysRouteImport
+ parentRoute: typeof AppRoute
}
- '/_app/transfer': {
- id: '/_app/transfer'
- path: '/transfer'
- fullPath: '/transfer'
- preLoaderRoute: typeof AppTransferRouteImport
+ '/_app/limits': {
+ id: '/_app/limits'
+ path: '/limits'
+ fullPath: '/limits'
+ preLoaderRoute: typeof AppLimitsRouteImport
parentRoute: typeof AppRoute
}
- '/_app/transactions': {
- id: '/_app/transactions'
- path: '/transactions'
- fullPath: '/transactions'
- preLoaderRoute: typeof AppTransactionsRouteImport
+ '/_app/overview': {
+ id: '/_app/overview'
+ path: '/overview'
+ fullPath: '/overview'
+ preLoaderRoute: typeof AppOverviewRouteImport
parentRoute: typeof AppRoute
}
- '/_app/settings': {
- id: '/_app/settings'
- path: '/settings'
- fullPath: '/settings'
- preLoaderRoute: typeof AppSettingsRouteImport
+ '/_app/quote': {
+ id: '/_app/quote'
+ path: '/quote'
+ fullPath: '/quote'
+ preLoaderRoute: typeof AppQuoteRouteImport
parentRoute: typeof AppRoute
}
'/_app/recipients': {
@@ -251,33 +244,40 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppRecipientsRouteImport
parentRoute: typeof AppRoute
}
- '/_app/quote': {
- id: '/_app/quote'
- path: '/quote'
- fullPath: '/quote'
- preLoaderRoute: typeof AppQuoteRouteImport
+ '/_app/settings': {
+ id: '/_app/settings'
+ path: '/settings'
+ fullPath: '/settings'
+ preLoaderRoute: typeof AppSettingsRouteImport
parentRoute: typeof AppRoute
}
- '/_app/overview': {
- id: '/_app/overview'
- path: '/overview'
- fullPath: '/overview'
- preLoaderRoute: typeof AppOverviewRouteImport
+ '/_app/transactions': {
+ id: '/_app/transactions'
+ path: '/transactions'
+ fullPath: '/transactions'
+ preLoaderRoute: typeof AppTransactionsRouteImport
parentRoute: typeof AppRoute
}
- '/_app/limits': {
- id: '/_app/limits'
- path: '/limits'
- fullPath: '/limits'
- preLoaderRoute: typeof AppLimitsRouteImport
+ '/_app/transfer': {
+ id: '/_app/transfer'
+ path: '/transfer'
+ fullPath: '/transfer'
+ preLoaderRoute: typeof AppTransferRouteImport
parentRoute: typeof AppRoute
}
- '/_app/api-keys': {
- id: '/_app/api-keys'
- path: '/api-keys'
- fullPath: '/api-keys'
- preLoaderRoute: typeof AppApiKeysRouteImport
- parentRoute: typeof AppRoute
+ '/invite/$token': {
+ id: '/invite/$token'
+ path: '/invite/$token'
+ fullPath: '/invite/$token'
+ preLoaderRoute: typeof InviteTokenRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/monerium/callback': {
+ id: '/monerium/callback'
+ path: '/monerium/callback'
+ fullPath: '/monerium/callback'
+ preLoaderRoute: typeof MoneriumCallbackRouteImport
+ parentRoute: typeof rootRouteImport
}
}
}
diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md
index 82c572ef6..4266a391b 100644
--- a/apps/frontend/CLAUDE.md
+++ b/apps/frontend/CLAUDE.md
@@ -10,6 +10,8 @@ frontend-scoped architecture, conventions, and commands. Run commands from
- **Forms**: React Hook Form with Zod validation (not Yup).
- **Data fetching**: TanStack Query.
- **Routing**: TanStack Router — route tree auto-generated in `src/routeTree.gen.ts` (do not hand-edit).
+- **Rendering**: TanStack Start. Marketing routes are prerendered to static HTML at build time
+ (`dist/client`); `/widget` sets `ssr: false` and is served from the `_shell.html` SPA shell.
- **State machines**: XState machines in `src/machines/` for complex flows (KYC, ramp process).
- **Wallets**: Wagmi/AppKit (EVM) + Talisman (Polkadot).
@@ -21,6 +23,17 @@ frontend-scoped architecture, conventions, and commands. Run commands from
- Extract complex conditional rendering into new components.
- Skip useless comments; only comment race conditions, TODOs, or genuinely confusing code.
+### Server-safe code (marketing routes)
+
+Anything rendered by a marketing route is prerendered in a DOM-less environment, so:
+
+- `useSyncExternalStore` must be given a server snapshot (third argument).
+- Probe browser globals with `typeof` — `localStorage` is *undeclared* on the server, so
+ `!localStorage` and `localStorage?.x` both throw a `ReferenceError`.
+- Size from CSS, not from a measured viewport, or the layout shifts on hydration.
+
+`src/tests/ssr-safety.test.tsx` guards these; keep it in the default `node` environment.
+
### XState v5
- Use `setup({ ... }).createMachine(...)` — not `createMachine` directly.
diff --git a/apps/frontend/_redirects b/apps/frontend/_redirects
index 9f42a4afb..f5065418d 100644
--- a/apps/frontend/_redirects
+++ b/apps/frontend/_redirects
@@ -2,21 +2,16 @@
/api/staging/* https://api-staging.vortexfinance.co/:splat 200
/api/sandbox/* https://api-sandbox.vortexfinance.co/:splat 200
-# Known app routes — serve SPA shell with real 200
-/ /index.html 200
-/business /index.html 200
-/payments /index.html 200
-/contact /index.html 200
-/privacy-policy /index.html 200
-/terms-and-conditions /index.html 200
-/terms-and-conditions-full /index.html 200
-/widget /index.html 200
-/en /index.html 200
-/en/* /index.html 200
-/pt-BR /index.html 200
-/pt-BR/* /index.html 200
-/pt-br /index.html 200
-/pt-br/* /index.html 200
+# The marketing routes are prerendered to static HTML (`/business/index.html`, `/pt-BR/...`),
+# and Netlify serves those files before applying any non-forced rule below.
+
+# The widget opts out of SSR, so it renders from the prerendered SPA shell.
+/widget /_shell.html 200
+/en/widget /_shell.html 200
+/pt-BR/widget /_shell.html 200
+
+# Support the lowercase widget alias without turning unknown localized paths into soft 404s.
+/pt-br/widget /_shell.html 200
# Everything else is a real 404
/* /404.html 404
diff --git a/apps/frontend/index.html b/apps/frontend/index.html
deleted file mode 100644
index 0ab58518c..000000000
--- a/apps/frontend/index.html
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Vortex
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/netlify.toml b/apps/frontend/netlify.toml
index 832aec9dc..9f3a5b148 100644
--- a/apps/frontend/netlify.toml
+++ b/apps/frontend/netlify.toml
@@ -4,10 +4,12 @@
# CAUTION: once this file is on the deployed branch it overrides the build settings
# configured in the Netlify UI — review it against the current UI configuration.
#
-# Redirect rules live in `_redirects` (copied into dist by the build), not here.
+# Redirect rules live in `_redirects` (copied into dist/client by the build), not here.
+# Marketing routes are prerendered to static HTML by TanStack Start, so they are plain files
+# under `dist/client`; only the SSR-disabled widget route needs a rewrite.
# The dashboard is a separate Netlify site (apps/dashboard/netlify.toml) served on its
# own domain; this build does not include it.
[build]
command = "cd ../.. && bun install --frozen-lockfile && bun run build:shared && bun run build:sdk && bun run build:frontend"
- publish = "dist"
+ publish = "dist/client"
diff --git a/apps/frontend/package.json b/apps/frontend/package.json
index 2a05b5a3a..61a8b0b17 100644
--- a/apps/frontend/package.json
+++ b/apps/frontend/package.json
@@ -29,12 +29,13 @@
"@talismn/connect-components": "^1.1.9",
"@talismn/connect-wallets": "^1.2.8",
"@tanstack/react-query": "^5.64.2",
- "@tanstack/react-router": "^1.136.8",
- "@tanstack/react-router-devtools": "^1.136.8",
+ "@tanstack/react-router": "^1.170.25",
+ "@tanstack/react-router-devtools": "^1.167.1",
+ "@tanstack/react-start": "^1.168.42",
"@tanstack/react-virtual": "^3.13.18",
- "@tanstack/zod-adapter": "^1.144.0",
+ "@tanstack/zod-adapter": "^1.167.0",
"@types/crypto-js": "^4.2.2",
- "@vitejs/plugin-react": "^4.3.4",
+ "@vitejs/plugin-react": "^5.2.0",
"@vortexfi/kyc": "workspace:*",
"@vortexfi/shared": "workspace:*",
"@wagmi/core": "catalog:",
@@ -88,7 +89,7 @@
"@polkadot/types-known": "catalog:",
"@storybook/react-vite": "^9.1.4",
"@tanstack/react-query-devtools": "^5.91.1",
- "@tanstack/router-plugin": "^1.136.8",
+ "@tanstack/router-plugin": "^1.168.29",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -117,7 +118,7 @@
"ts-node": "^10.9.1",
"tw-animate-css": "^1.4.0",
"typescript": "catalog:",
- "vite": "^6.2.6",
+ "vite": "^7.3.5",
"vite-plugin-node-polyfills": "^0.23.0",
"vitest": "^3.1.1"
},
@@ -129,7 +130,7 @@
"name": "vortex-frontend",
"private": true,
"scripts": {
- "build": "bun x --bun vite build && cp -R src/assets/coins dist/assets/coins && cp _redirects dist/_redirects",
+ "build": "bun x --bun vite build && cp -R src/assets/coins dist/client/assets/coins && cp _redirects dist/client/_redirects",
"build-storybook": "storybook build",
"dev": "rm -rf node_modules/.vite && bun x --bun vite --host",
"preview": "bun x --bun vite preview",
diff --git a/apps/frontend/src/client.test.tsx b/apps/frontend/src/client.test.tsx
new file mode 100644
index 000000000..81ad73c34
--- /dev/null
+++ b/apps/frontend/src/client.test.tsx
@@ -0,0 +1,73 @@
+// @vitest-environment jsdom
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ addIntegration: vi.fn(),
+ captureException: vi.fn(),
+ getUserId: vi.fn(),
+ hydrateRoot: vi.fn(),
+ hydrateStart: vi.fn(),
+ initializeEvmTokens: vi.fn(),
+ init: vi.fn(),
+ replayIntegration: vi.fn(() => ({ name: "Replay" })),
+ routerTracingIntegration: vi.fn(() => ({ name: "TanStackRouterTracing" }))
+}));
+
+vi.mock("@sentry/react", () => ({
+ addIntegration: mocks.addIntegration,
+ captureException: mocks.captureException,
+ init: mocks.init,
+ reactErrorHandler: vi.fn(() => vi.fn()),
+ replayIntegration: mocks.replayIntegration,
+ setUser: vi.fn(),
+ tanstackRouterBrowserTracingIntegration: mocks.routerTracingIntegration
+}));
+vi.mock("@tanstack/react-router", () => ({ RouterProvider: vi.fn() }));
+vi.mock("@tanstack/react-start/client", () => ({ hydrateStart: mocks.hydrateStart }));
+vi.mock("react-dom/client", () => ({ hydrateRoot: mocks.hydrateRoot }));
+vi.mock("./config", () => ({ config: { env: "test", isProd: false } }));
+vi.mock("./helpers/googleTranslate", () => ({}));
+vi.mock("./helpers/sentry", () => ({
+ SENTRY_DENY_URLS: [],
+ SENTRY_IGNORE_ERRORS: [],
+ sentryBeforeSend: vi.fn()
+}));
+vi.mock("./services/auth", () => ({ AuthService: { getUserId: mocks.getUserId } }));
+vi.mock("./services/tokens", () => ({ initializeEvmTokens: mocks.initializeEvmTokens }));
+
+describe("client hydration monitoring", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.stubEnv("VITE_SENTRY_DSN", "https://public@example.invalid/1");
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllEnvs();
+ });
+
+ it("initializes monitoring before router hydration and reports startup failures", async () => {
+ const hydrationError = new Error("router hydration failed");
+ mocks.hydrateStart.mockRejectedValueOnce(hydrationError);
+
+ await import("./client");
+ await vi.waitFor(() => expect(mocks.captureException).toHaveBeenCalledWith(hydrationError));
+
+ expect(mocks.init.mock.invocationCallOrder[0]).toBeLessThan(mocks.hydrateStart.mock.invocationCallOrder[0]);
+ expect(mocks.hydrateRoot).not.toHaveBeenCalled();
+ });
+
+ it("adds router tracing when hydration produces the router", async () => {
+ const router = { id: "router" };
+ const tracingIntegration = { name: "TanStackRouterTracing" };
+ mocks.hydrateStart.mockResolvedValueOnce(router);
+ mocks.routerTracingIntegration.mockReturnValueOnce(tracingIntegration);
+
+ await import("./client");
+ await vi.waitFor(() => expect(mocks.hydrateRoot).toHaveBeenCalled());
+
+ expect(mocks.routerTracingIntegration).toHaveBeenCalledWith(router);
+ expect(mocks.addIntegration).toHaveBeenCalledWith(tracingIntegration);
+ });
+});
diff --git a/apps/frontend/src/client.tsx b/apps/frontend/src/client.tsx
new file mode 100644
index 000000000..481968906
--- /dev/null
+++ b/apps/frontend/src/client.tsx
@@ -0,0 +1,74 @@
+import * as Sentry from "@sentry/react";
+import { RouterProvider } from "@tanstack/react-router";
+import { hydrateStart } from "@tanstack/react-start/client";
+import { startTransition } from "react";
+import { hydrateRoot } from "react-dom/client";
+import { config } from "./config";
+import { SENTRY_DENY_URLS, SENTRY_IGNORE_ERRORS, sentryBeforeSend } from "./helpers/sentry";
+import { AuthService } from "./services/auth";
+import { initializeEvmTokens } from "./services/tokens";
+import "./helpers/googleTranslate";
+
+// Sentry must initialize before router hydration so startup failures are reportable. The
+// router tracing integration is attached once Start hands over the router instance.
+function initSentry(): boolean {
+ const sentryDsn = import.meta.env.VITE_SENTRY_DSN;
+ if (!sentryDsn) {
+ return false;
+ }
+
+ Sentry.init({
+ beforeSend: sentryBeforeSend,
+ denyUrls: SENTRY_DENY_URLS,
+ dsn: sentryDsn,
+ enabled: !window.location.hostname.includes("localhost"), // Disable sentry entirely when testing locally
+ environment: config.env, // production | staging | development — keeps preview/QA noise out of prod
+ ignoreErrors: SENTRY_IGNORE_ERRORS,
+ // Explicit replay masking — these are the defaults, but pinned for a KYC/KYB app so a future
+ // default change can't start leaking user input into replays.
+ integrations: [Sentry.replayIntegration({ blockAllMedia: true, maskAllText: true })],
+ // Capture 100% of sessions where an error occurs; sample plain sessions only in prod.
+ replaysOnErrorSampleRate: 1.0,
+ replaysSessionSampleRate: config.isProd ? 0.1 : 1.0,
+ // Only propagate trace headers to our own (same-origin) API. The API is served same-origin
+ // (/api/...), so this works across all Netlify branch URLs and avoids leaking headers to
+ // third parties (Squid, RPCs).
+ tracePropagationTargets: [window.location.origin],
+ tracesSampleRate: config.isProd ? 0.2 : 1.0
+ });
+
+ // On a page reload the session is restored from localStorage without calling storeTokens, so
+ // seed the Sentry user here too (pseudonymous id only). Runtime login/logout keeps it in sync.
+ const restoredUserId = AuthService.getUserId();
+ if (restoredUserId) {
+ Sentry.setUser({ id: restoredUserId });
+ }
+
+ return true;
+}
+
+const sentryInitialized = initSentry();
+
+// Initialize dynamic EVM tokens from SquidRouter API (falls back to static config on failure)
+initializeEvmTokens();
+
+hydrateStart()
+ .then(router => {
+ if (sentryInitialized) {
+ Sentry.addIntegration(Sentry.tanstackRouterBrowserTracingIntegration(router));
+ }
+
+ startTransition(() => {
+ hydrateRoot(document, , {
+ onCaughtError: Sentry.reactErrorHandler(),
+ onRecoverableError: Sentry.reactErrorHandler(),
+ onUncaughtError: Sentry.reactErrorHandler()
+ });
+ });
+ })
+ .catch(error => {
+ // Hydration never produced a router, so the app will never mount. On a prerendered marketing
+ // route the visitor is left with static, inert HTML; on `/widget` (ssr: false) the page stays
+ // blank. Report the failure through the base monitoring initialized above.
+ Sentry.captureException(error);
+ });
diff --git a/apps/frontend/src/components/Globe/index.tsx b/apps/frontend/src/components/Globe/index.tsx
index cc928475b..b85a7938d 100644
--- a/apps/frontend/src/components/Globe/index.tsx
+++ b/apps/frontend/src/components/Globe/index.tsx
@@ -21,6 +21,11 @@ const GLOBE_SIZES = {
sm: 560
} as const;
+// The rendered size comes from CSS, not from `size`, so the prerendered HTML is already correct
+// at every breakpoint instead of shifting on hydration. Tailwind only sees literal class strings,
+// so these must be spelled out; they mirror GLOBE_SIZES and the breakpoints in getGlobeSize.
+const GLOBE_SIZE_CLASSES = "h-[560px] w-[560px] sm:h-[780px] sm:w-[780px] lg:h-[960px] lg:w-[960px]";
+
const CURRENCY_MARKERS = [
{ currency: "usd", icon: USD_ICON, lat: 38.91, lng: -77.04 },
{ currency: "brl", icon: BRL_ICON, lat: -15.8, lng: -47.89 },
@@ -31,6 +36,8 @@ const CURRENCY_MARKERS = [
] as const;
function getGlobeSize(): number {
+ // No viewport during SSR/prerender; the size is corrected on hydration.
+ if (typeof window === "undefined") return GLOBE_SIZES.lg;
if (window.matchMedia("(min-width: 1024px)").matches) return GLOBE_SIZES.lg;
if (window.matchMedia("(min-width: 640px)").matches) return GLOBE_SIZES.md;
return GLOBE_SIZES.sm;
@@ -185,16 +192,14 @@ export const Globe = ({ className }: GlobeProps) => {
return (
-
+ {/* cobe replaces the backing store with a devicePixelRatio-scaled buffer on mount, and
+ nothing is drawn before then, so these attributes only need to match between the
+ server and client render. */}
+
{CURRENCY_MARKERS.map((m, i) => (
{
const { openTokenSelectModal } = useTokenSelectionActions();
- const evmTokensLoaded = useSyncExternalStore(subscribeEvmTokensLoaded, getEvmTokensLoadedSnapshot);
+ const evmTokensLoaded = useEvmTokensLoaded();
const tokenLoading = isNetworkEVM(selectedNetwork as Networks) && !evmTokensLoaded;
const fromToken = getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken, getEvmTokenConfig());
diff --git a/apps/frontend/src/components/Ramp/Onramp/index.tsx b/apps/frontend/src/components/Ramp/Onramp/index.tsx
index 6e51bf968..f434353a9 100644
--- a/apps/frontend/src/components/Ramp/Onramp/index.tsx
+++ b/apps/frontend/src/components/Ramp/Onramp/index.tsx
@@ -1,13 +1,6 @@
-import {
- getAnyFiatTokenDetails,
- getEvmTokensLoadedSnapshot,
- getOnChainTokenDetailsOrDefault,
- isNetworkEVM,
- Networks,
- subscribeEvmTokensLoaded
-} from "@vortexfi/shared";
+import { getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, isNetworkEVM, Networks } from "@vortexfi/shared";
import { motion } from "motion/react";
-import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useEventsContext } from "../../../contexts/events";
@@ -16,6 +9,7 @@ import { useQuoteForm } from "../../../hooks/quote/useQuoteForm";
import { useQuoteService } from "../../../hooks/quote/useQuoteService";
import { useRampSubmission } from "../../../hooks/ramp/useRampSubmission";
import { useRampValidation } from "../../../hooks/ramp/useRampValidation";
+import { useEvmTokensLoaded } from "../../../hooks/useEvmTokensLoaded";
import { useTokenIcon } from "../../../hooks/useTokenIcon";
import { getEvmTokenConfig } from "../../../services/tokens";
import { useFeeComparisonStore } from "../../../stores/feeComparison";
@@ -58,7 +52,7 @@ export const Onramp = () => {
const { openTokenSelectModal } = useTokenSelectionActions();
- const evmTokensLoaded = useSyncExternalStore(subscribeEvmTokensLoaded, getEvmTokensLoadedSnapshot);
+ const evmTokensLoaded = useEvmTokensLoaded();
const tokenLoading = isNetworkEVM(selectedNetwork as Networks) && !evmTokensLoaded;
const fromToken = getAnyFiatTokenDetails(fiatToken);
diff --git a/apps/frontend/src/components/ToastPopover/index.tsx b/apps/frontend/src/components/ToastPopover/index.tsx
index e653d6237..d6051e659 100644
--- a/apps/frontend/src/components/ToastPopover/index.tsx
+++ b/apps/frontend/src/components/ToastPopover/index.tsx
@@ -22,7 +22,7 @@ function getSnapshot() {
}
export function useHasActiveToasts() {
- return useSyncExternalStore(subscribe, getSnapshot) > 0;
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) > 0;
}
export const ToastPopover = (props: ToastContainerProps) => {
diff --git a/apps/frontend/src/config/supabase.ts b/apps/frontend/src/config/supabase.ts
index 609691faf..087075f5f 100644
--- a/apps/frontend/src/config/supabase.ts
+++ b/apps/frontend/src/config/supabase.ts
@@ -1,18 +1,28 @@
import { createClient } from "@supabase/supabase-js";
-const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
-const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
+let supabaseClient: ReturnType | undefined;
-if (!supabaseUrl || !supabaseAnonKey) {
- throw new Error("Missing Supabase environment variables");
-}
+export function getSupabaseClient(): ReturnType {
+ if (supabaseClient) {
+ return supabaseClient;
+ }
+
+ const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
+ const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
-export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
- auth: {
- // The app owns token storage and refresh (backend /auth/refresh + a single scheduler).
- // Keep the client passive so it doesn't run a competing background refresh.
- autoRefreshToken: false,
- detectSessionInUrl: false,
- persistSession: false
+ if (!supabaseUrl || !supabaseAnonKey) {
+ throw new Error("Missing Supabase environment variables");
}
-});
+
+ supabaseClient = createClient(supabaseUrl, supabaseAnonKey, {
+ auth: {
+ // The app owns token storage and refresh (backend /auth/refresh + a single scheduler).
+ // Keep the client passive so it doesn't run a competing background refresh.
+ autoRefreshToken: false,
+ detectSessionInUrl: false,
+ persistSession: false
+ }
+ });
+
+ return supabaseClient;
+}
diff --git a/apps/frontend/src/contexts/rampState.tsx b/apps/frontend/src/contexts/rampState.tsx
index 725676a37..88b269e69 100644
--- a/apps/frontend/src/contexts/rampState.tsx
+++ b/apps/frontend/src/contexts/rampState.tsx
@@ -24,6 +24,11 @@ const TOKEN_REFRESH_RETRY_MS = 30 * 1000; // retry after a transient failure
export { readRampEphemerals, removeRampEphemeral, updateRampEphemeral } from "../services/rampEphemerals";
function readPersistedRampState(): Snapshot | undefined {
+ // No persisted state to restore during SSR/prerender; the client re-reads it on hydration.
+ if (typeof localStorage === "undefined") {
+ return undefined;
+ }
+
try {
const raw = localStorage.getItem(RAMP_STATE_STORAGE_KEY);
if (!raw) return undefined;
diff --git a/apps/frontend/src/hooks/useAuthTokens.ts b/apps/frontend/src/hooks/useAuthTokens.ts
index 17831fe66..81cf94d1d 100644
--- a/apps/frontend/src/hooks/useAuthTokens.ts
+++ b/apps/frontend/src/hooks/useAuthTokens.ts
@@ -1,7 +1,7 @@
import { useSelector } from "@xstate/react";
import { useCallback, useEffect, useRef } from "react";
import type { ActorRefFrom } from "xstate";
-import { supabase } from "../config/supabase";
+import { getSupabaseClient } from "../config/supabase";
import type { rampMachine } from "../machines/ramp.machine";
import { AuthService } from "../services/auth";
@@ -20,8 +20,8 @@ export function useAuthTokens(actorRef: ActorRefFrom) {
const urlTokens = AuthService.handleUrlTokens();
if (urlTokens) {
// Use the URL tokens to set session with Supabase, then get full user details
- supabase.auth
- .setSession({
+ getSupabaseClient()
+ .auth.setSession({
access_token: urlTokens.accessToken,
refresh_token: urlTokens.refreshToken
})
diff --git a/apps/frontend/src/hooks/useEvmTokensLoaded.ts b/apps/frontend/src/hooks/useEvmTokensLoaded.ts
new file mode 100644
index 000000000..76f2a5b51
--- /dev/null
+++ b/apps/frontend/src/hooks/useEvmTokensLoaded.ts
@@ -0,0 +1,7 @@
+import { getEvmTokensLoadedSnapshot, subscribeEvmTokensLoaded } from "@vortexfi/shared";
+import { useSyncExternalStore } from "react";
+
+// The snapshot is a plain boolean read, so the same getter serves as the server snapshot —
+// without one, prerendering the routes that call this throws "Missing getServerSnapshot".
+export const useEvmTokensLoaded = (): boolean =>
+ useSyncExternalStore(subscribeEvmTokensLoaded, getEvmTokensLoadedSnapshot, getEvmTokensLoadedSnapshot);
diff --git a/apps/frontend/src/hooks/useRampUrlParams.ts b/apps/frontend/src/hooks/useRampUrlParams.ts
index 1df44a70e..5f26a2c55 100644
--- a/apps/frontend/src/hooks/useRampUrlParams.ts
+++ b/apps/frontend/src/hooks/useRampUrlParams.ts
@@ -6,7 +6,6 @@ import {
EvmToken,
FiatToken,
getEvmTokenConfig,
- getEvmTokensLoadedSnapshot,
isNetworkEVM,
logger,
mapFiatToDestination,
@@ -15,11 +14,10 @@ import {
OnChainTokenSymbol,
PaymentMethod,
QuoteResponse,
- RampDirection,
- subscribeEvmTokensLoaded
+ RampDirection
} from "@vortexfi/shared";
import Big from "big.js";
-import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
import { isFrontendNetworkEnabled } from "../config/networkAvailability";
import { getFirstEnabledFiatToken, isFiatTokenEnabled } from "../config/tokenAvailability";
import { useNetwork } from "../contexts/network";
@@ -31,6 +29,7 @@ import { useQuoteFormStoreActions } from "../stores/quote/useQuoteFormStore";
import { useQuoteStore } from "../stores/quote/useQuoteStore";
import { useRampDirection, useRampDirectionToggle } from "../stores/rampDirectionStore";
import { RampSearchParams } from "../types/searchParams";
+import { useEvmTokensLoaded } from "./useEvmTokensLoaded";
import { useWidgetMode } from "./useWidgetMode";
interface RampUrlParams {
@@ -180,7 +179,7 @@ export const useRampUrlParams = (): RampUrlParams => {
const searchParams = useSearch({ strict: false }) as RampSearchParams;
const { selectedNetwork } = useNetwork();
const rampDirectionStore = useRampDirection();
- const evmTokensLoaded = useSyncExternalStore(subscribeEvmTokensLoaded, getEvmTokensLoadedSnapshot);
+ const evmTokensLoaded = useEvmTokensLoaded();
const urlParams = useMemo(() => {
const rampDirectionParam = searchParams.rampType?.toUpperCase();
diff --git a/apps/frontend/src/hooks/useSyncFormToUrl.ts b/apps/frontend/src/hooks/useSyncFormToUrl.ts
index 7276af1f9..d9f2a6deb 100644
--- a/apps/frontend/src/hooks/useSyncFormToUrl.ts
+++ b/apps/frontend/src/hooks/useSyncFormToUrl.ts
@@ -1,9 +1,10 @@
import { useNavigate } from "@tanstack/react-router";
-import { getEvmTokensLoadedSnapshot, isNetworkEVM, Networks, subscribeEvmTokensLoaded } from "@vortexfi/shared";
-import { useEffect, useSyncExternalStore } from "react";
+import { isNetworkEVM, Networks } from "@vortexfi/shared";
+import { useEffect } from "react";
import { useNetwork } from "../contexts/network";
import { useFiatToken, useInputAmount, useOnChainToken } from "../stores/quote/useQuoteFormStore";
import { useRampDirection } from "../stores/rampDirectionStore";
+import { useEvmTokensLoaded } from "./useEvmTokensLoaded";
export const useSyncFormToUrl = () => {
const inputAmount = useInputAmount();
@@ -12,7 +13,7 @@ export const useSyncFormToUrl = () => {
const rampDirection = useRampDirection();
const { selectedNetwork } = useNetwork();
const navigate = useNavigate();
- const evmTokensLoaded = useSyncExternalStore(subscribeEvmTokensLoaded, getEvmTokensLoadedSnapshot);
+ const evmTokensLoaded = useEvmTokensLoaded();
useEffect(() => {
navigate({
diff --git a/apps/frontend/src/i18n.ts b/apps/frontend/src/i18n.ts
new file mode 100644
index 000000000..5685648df
--- /dev/null
+++ b/apps/frontend/src/i18n.ts
@@ -0,0 +1,26 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import enTranslations from "./translations/en.json";
+import { getBrowserLanguage, Language } from "./translations/helpers";
+import ptTranslations from "./translations/pt.json";
+
+// Initialize i18n with browser language as default (falls back to English during SSR,
+// where there is no navigator). The actual language is set by the locale route's beforeLoad.
+//
+// This is a module singleton whose language the `{-$locale}` route switches on every
+// navigation. Prerendering therefore runs with `concurrency: 1` (see vite.config.ts) so that
+// pages cannot race over the active language — do not make either side concurrent alone.
+i18n.use(initReactI18next).init({
+ fallbackLng: "en",
+ lng: getBrowserLanguage(),
+ resources: {
+ [Language.English]: {
+ translation: enTranslations
+ },
+ [Language.Portuguese_Brazil]: {
+ translation: ptTranslations
+ }
+ }
+});
+
+export default i18n;
diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx
deleted file mode 100644
index b1833fd99..000000000
--- a/apps/frontend/src/main.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import "@fontsource/roboto/300.css";
-import "@fontsource/roboto/400.css";
-import "@fontsource/roboto/500.css";
-import "@fontsource/roboto/700.css";
-import "react-toastify/dist/ReactToastify.css";
-import "../App.css";
-
-import * as Sentry from "@sentry/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
-import { createRouter, RouterProvider } from "@tanstack/react-router";
-import i18n from "i18next";
-import { createRoot } from "react-dom/client";
-import { initReactI18next } from "react-i18next";
-import { WagmiProvider } from "wagmi";
-import { config } from "./config";
-import { PolkadotNodeProvider } from "./contexts/polkadotNode";
-import { PolkadotWalletStateProvider } from "./contexts/polkadotWallet";
-import { SENTRY_DENY_URLS, SENTRY_IGNORE_ERRORS, sentryBeforeSend } from "./helpers/sentry";
-import { AuthService } from "./services/auth";
-import { initializeEvmTokens } from "./services/tokens";
-import { wagmiConfig } from "./wagmiConfig";
-import "./helpers/googleTranslate";
-import { PersistentRampStateProvider } from "./contexts/rampState";
-import { routeTree } from "./routeTree.gen";
-import enTranslations from "./translations/en.json";
-import { getBrowserLanguage, Language } from "./translations/helpers";
-import ptTranslations from "./translations/pt.json";
-
-const queryClient = new QueryClient();
-
-// Initialize i18n with browser language as default
-// The actual language will be set by the route's beforeLoad
-const lng = getBrowserLanguage();
-
-i18n.use(initReactI18next).init({
- fallbackLng: "en",
- lng,
- resources: {
- [Language.English]: {
- translation: enTranslations
- },
- [Language.Portuguese_Brazil]: {
- translation: ptTranslations
- }
- }
-});
-
-const router = createRouter({ routeTree });
-
-declare module "@tanstack/react-router" {
- interface Register {
- router: typeof router;
- }
-}
-
-// Sentry must initialize before the app renders. The TanStack Router tracing
-// integration needs the router instance, so init runs after the router is created.
-const sentryDsn = import.meta.env.VITE_SENTRY_DSN;
-if (sentryDsn) {
- Sentry.init({
- beforeSend: sentryBeforeSend,
- denyUrls: SENTRY_DENY_URLS,
- dsn: sentryDsn,
- enabled: !window.location.hostname.includes("localhost"), // Disable sentry entirely when testing locally
- environment: config.env, // production | staging | development — keeps preview/QA noise out of prod
- ignoreErrors: SENTRY_IGNORE_ERRORS,
- // Explicit replay masking — these are the defaults, but pinned for a KYC/KYB app so a future
- // default change can't start leaking user input into replays.
- integrations: [
- Sentry.tanstackRouterBrowserTracingIntegration(router),
- Sentry.replayIntegration({ blockAllMedia: true, maskAllText: true })
- ],
- // Capture 100% of sessions where an error occurs; sample plain sessions only in prod.
- replaysOnErrorSampleRate: 1.0,
- replaysSessionSampleRate: config.isProd ? 0.1 : 1.0,
- // Only propagate trace headers to our own (same-origin) API. The API is served same-origin
- // (/api/...), so this works across all Netlify branch URLs and avoids leaking headers to
- // third parties (Squid, RPCs).
- tracePropagationTargets: [window.location.origin],
- tracesSampleRate: config.isProd ? 0.2 : 1.0
- });
-
- // On a page reload the session is restored from localStorage without calling storeTokens, so
- // seed the Sentry user here too (pseudonymous id only). Runtime login/logout keeps it in sync.
- const restoredUserId = AuthService.getUserId();
- if (restoredUserId) {
- Sentry.setUser({ id: restoredUserId });
- }
-}
-
-const root = document.getElementById("app");
-
-if (!root) {
- throw new Error("Root element not found");
-}
-
-// Initialize dynamic EVM tokens from SquidRouter API (falls back to static config on failure)
-initializeEvmTokens();
-
-createRoot(root, {
- onCaughtError: Sentry.reactErrorHandler(),
- onRecoverableError: Sentry.reactErrorHandler(),
- onUncaughtError: Sentry.reactErrorHandler()
-}).render(
-
-
-
-
-
-
-
-
-
-
-
-
-);
diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts
index 5c94dcb58..40a491f20 100644
--- a/apps/frontend/src/routeTree.gen.ts
+++ b/apps/frontend/src/routeTree.gen.ts
@@ -11,13 +11,13 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as Char123LocaleChar125RouteImport } from './routes/{-$locale}'
import { Route as Char123LocaleChar125IndexRouteImport } from './routes/{-$locale}/index'
-import { Route as Char123LocaleChar125WidgetRouteImport } from './routes/{-$locale}/widget'
-import { Route as Char123LocaleChar125TermsAndConditionsFullRouteImport } from './routes/{-$locale}/terms-and-conditions-full'
-import { Route as Char123LocaleChar125TermsAndConditionsRouteImport } from './routes/{-$locale}/terms-and-conditions'
-import { Route as Char123LocaleChar125PrivacyPolicyRouteImport } from './routes/{-$locale}/privacy-policy'
-import { Route as Char123LocaleChar125PaymentsRouteImport } from './routes/{-$locale}/payments'
-import { Route as Char123LocaleChar125ContactRouteImport } from './routes/{-$locale}/contact'
import { Route as Char123LocaleChar125BusinessRouteImport } from './routes/{-$locale}/business'
+import { Route as Char123LocaleChar125ContactRouteImport } from './routes/{-$locale}/contact'
+import { Route as Char123LocaleChar125PaymentsRouteImport } from './routes/{-$locale}/payments'
+import { Route as Char123LocaleChar125PrivacyPolicyRouteImport } from './routes/{-$locale}/privacy-policy'
+import { Route as Char123LocaleChar125TermsAndConditionsRouteImport } from './routes/{-$locale}/terms-and-conditions'
+import { Route as Char123LocaleChar125TermsAndConditionsFullRouteImport } from './routes/{-$locale}/terms-and-conditions-full'
+import { Route as Char123LocaleChar125WidgetRouteImport } from './routes/{-$locale}/widget'
const Char123LocaleChar125Route = Char123LocaleChar125RouteImport.update({
id: '/{-$locale}',
@@ -30,22 +30,22 @@ const Char123LocaleChar125IndexRoute =
path: '/',
getParentRoute: () => Char123LocaleChar125Route,
} as any)
-const Char123LocaleChar125WidgetRoute =
- Char123LocaleChar125WidgetRouteImport.update({
- id: '/widget',
- path: '/widget',
+const Char123LocaleChar125BusinessRoute =
+ Char123LocaleChar125BusinessRouteImport.update({
+ id: '/business',
+ path: '/business',
getParentRoute: () => Char123LocaleChar125Route,
} as any)
-const Char123LocaleChar125TermsAndConditionsFullRoute =
- Char123LocaleChar125TermsAndConditionsFullRouteImport.update({
- id: '/terms-and-conditions-full',
- path: '/terms-and-conditions-full',
+const Char123LocaleChar125ContactRoute =
+ Char123LocaleChar125ContactRouteImport.update({
+ id: '/contact',
+ path: '/contact',
getParentRoute: () => Char123LocaleChar125Route,
} as any)
-const Char123LocaleChar125TermsAndConditionsRoute =
- Char123LocaleChar125TermsAndConditionsRouteImport.update({
- id: '/terms-and-conditions',
- path: '/terms-and-conditions',
+const Char123LocaleChar125PaymentsRoute =
+ Char123LocaleChar125PaymentsRouteImport.update({
+ id: '/payments',
+ path: '/payments',
getParentRoute: () => Char123LocaleChar125Route,
} as any)
const Char123LocaleChar125PrivacyPolicyRoute =
@@ -54,22 +54,22 @@ const Char123LocaleChar125PrivacyPolicyRoute =
path: '/privacy-policy',
getParentRoute: () => Char123LocaleChar125Route,
} as any)
-const Char123LocaleChar125PaymentsRoute =
- Char123LocaleChar125PaymentsRouteImport.update({
- id: '/payments',
- path: '/payments',
+const Char123LocaleChar125TermsAndConditionsRoute =
+ Char123LocaleChar125TermsAndConditionsRouteImport.update({
+ id: '/terms-and-conditions',
+ path: '/terms-and-conditions',
getParentRoute: () => Char123LocaleChar125Route,
} as any)
-const Char123LocaleChar125ContactRoute =
- Char123LocaleChar125ContactRouteImport.update({
- id: '/contact',
- path: '/contact',
+const Char123LocaleChar125TermsAndConditionsFullRoute =
+ Char123LocaleChar125TermsAndConditionsFullRouteImport.update({
+ id: '/terms-and-conditions-full',
+ path: '/terms-and-conditions-full',
getParentRoute: () => Char123LocaleChar125Route,
} as any)
-const Char123LocaleChar125BusinessRoute =
- Char123LocaleChar125BusinessRouteImport.update({
- id: '/business',
- path: '/business',
+const Char123LocaleChar125WidgetRoute =
+ Char123LocaleChar125WidgetRouteImport.update({
+ id: '/widget',
+ path: '/widget',
getParentRoute: () => Char123LocaleChar125Route,
} as any)
@@ -161,25 +161,25 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof Char123LocaleChar125IndexRouteImport
parentRoute: typeof Char123LocaleChar125Route
}
- '/{-$locale}/widget': {
- id: '/{-$locale}/widget'
- path: '/widget'
- fullPath: '/{-$locale}/widget'
- preLoaderRoute: typeof Char123LocaleChar125WidgetRouteImport
+ '/{-$locale}/business': {
+ id: '/{-$locale}/business'
+ path: '/business'
+ fullPath: '/{-$locale}/business'
+ preLoaderRoute: typeof Char123LocaleChar125BusinessRouteImport
parentRoute: typeof Char123LocaleChar125Route
}
- '/{-$locale}/terms-and-conditions-full': {
- id: '/{-$locale}/terms-and-conditions-full'
- path: '/terms-and-conditions-full'
- fullPath: '/{-$locale}/terms-and-conditions-full'
- preLoaderRoute: typeof Char123LocaleChar125TermsAndConditionsFullRouteImport
+ '/{-$locale}/contact': {
+ id: '/{-$locale}/contact'
+ path: '/contact'
+ fullPath: '/{-$locale}/contact'
+ preLoaderRoute: typeof Char123LocaleChar125ContactRouteImport
parentRoute: typeof Char123LocaleChar125Route
}
- '/{-$locale}/terms-and-conditions': {
- id: '/{-$locale}/terms-and-conditions'
- path: '/terms-and-conditions'
- fullPath: '/{-$locale}/terms-and-conditions'
- preLoaderRoute: typeof Char123LocaleChar125TermsAndConditionsRouteImport
+ '/{-$locale}/payments': {
+ id: '/{-$locale}/payments'
+ path: '/payments'
+ fullPath: '/{-$locale}/payments'
+ preLoaderRoute: typeof Char123LocaleChar125PaymentsRouteImport
parentRoute: typeof Char123LocaleChar125Route
}
'/{-$locale}/privacy-policy': {
@@ -189,25 +189,25 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof Char123LocaleChar125PrivacyPolicyRouteImport
parentRoute: typeof Char123LocaleChar125Route
}
- '/{-$locale}/payments': {
- id: '/{-$locale}/payments'
- path: '/payments'
- fullPath: '/{-$locale}/payments'
- preLoaderRoute: typeof Char123LocaleChar125PaymentsRouteImport
+ '/{-$locale}/terms-and-conditions': {
+ id: '/{-$locale}/terms-and-conditions'
+ path: '/terms-and-conditions'
+ fullPath: '/{-$locale}/terms-and-conditions'
+ preLoaderRoute: typeof Char123LocaleChar125TermsAndConditionsRouteImport
parentRoute: typeof Char123LocaleChar125Route
}
- '/{-$locale}/contact': {
- id: '/{-$locale}/contact'
- path: '/contact'
- fullPath: '/{-$locale}/contact'
- preLoaderRoute: typeof Char123LocaleChar125ContactRouteImport
+ '/{-$locale}/terms-and-conditions-full': {
+ id: '/{-$locale}/terms-and-conditions-full'
+ path: '/terms-and-conditions-full'
+ fullPath: '/{-$locale}/terms-and-conditions-full'
+ preLoaderRoute: typeof Char123LocaleChar125TermsAndConditionsFullRouteImport
parentRoute: typeof Char123LocaleChar125Route
}
- '/{-$locale}/business': {
- id: '/{-$locale}/business'
- path: '/business'
- fullPath: '/{-$locale}/business'
- preLoaderRoute: typeof Char123LocaleChar125BusinessRouteImport
+ '/{-$locale}/widget': {
+ id: '/{-$locale}/widget'
+ path: '/widget'
+ fullPath: '/{-$locale}/widget'
+ preLoaderRoute: typeof Char123LocaleChar125WidgetRouteImport
parentRoute: typeof Char123LocaleChar125Route
}
}
@@ -247,3 +247,12 @@ const rootRouteChildren: RootRouteChildren = {
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/react-start'
+declare module '@tanstack/react-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/apps/frontend/src/router.tsx b/apps/frontend/src/router.tsx
new file mode 100644
index 000000000..7de9e741b
--- /dev/null
+++ b/apps/frontend/src/router.tsx
@@ -0,0 +1,21 @@
+import { QueryClient } from "@tanstack/react-query";
+import { createRouter } from "@tanstack/react-router";
+import "./i18n";
+import { routeTree } from "./routeTree.gen";
+
+// Called once per request on the server and once on the client, so the QueryClient is
+// created per router instance rather than shared across renders.
+export function getRouter() {
+ const queryClient = new QueryClient();
+
+ return createRouter({
+ context: { queryClient },
+ routeTree
+ });
+}
+
+declare module "@tanstack/react-router" {
+ interface Register {
+ router: ReturnType;
+ }
+}
diff --git a/apps/frontend/src/routes/__root.tsx b/apps/frontend/src/routes/__root.tsx
index 05fe00243..107254fb1 100644
--- a/apps/frontend/src/routes/__root.tsx
+++ b/apps/frontend/src/routes/__root.tsx
@@ -1,20 +1,115 @@
-import { createRootRoute, Outlet } from "@tanstack/react-router";
+import "@fontsource/roboto/300.css";
+import "@fontsource/roboto/400.css";
+import "@fontsource/roboto/500.css";
+import "@fontsource/roboto/700.css";
+import "react-toastify/dist/ReactToastify.css";
+import "../../App.css";
+
+import type { QueryClient } from "@tanstack/react-query";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { ClientOnly, createRootRouteWithContext, HeadContent, Outlet, Scripts, useParams } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
+import { PropsWithChildren } from "react";
+import { WagmiProvider } from "wagmi";
import { ToastPopover } from "../components/ToastPopover";
import { EventsProvider } from "../contexts/events";
import { NetworkProvider } from "../contexts/network";
+import { PolkadotNodeProvider } from "../contexts/polkadotNode";
+import { PolkadotWalletStateProvider } from "../contexts/polkadotWallet";
+import { PersistentRampStateProvider } from "../contexts/rampState";
+import { Language } from "../translations/helpers";
+import { wagmiConfig } from "../wagmiConfig";
+
+const GTM_ID = "GTM-T8JZSLD8";
+
+const GTM_SNIPPET = `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
+new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
+j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
+'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
+})(window,document,'script','dataLayer','${GTM_ID}');`;
+
+export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
+ component: RootComponent,
+ head: () => ({
+ links: [
+ { href: "/favicon-32x32.png", rel: "icon", sizes: "32x32", type: "image/png" },
+ { href: "/favicon-16x16.png", rel: "icon", sizes: "16x16", type: "image/png" },
+ { href: "/favicon.ico", rel: "icon", type: "image/x-icon" },
+ { href: "/apple-touch-icon.png", rel: "apple-touch-icon", sizes: "180x180" },
+ { href: "/site.webmanifest", rel: "manifest" },
+ { href: "https://fonts.googleapis.com", rel: "preconnect" },
+ { crossOrigin: "anonymous", href: "https://fonts.gstatic.com", rel: "preconnect" },
+ {
+ href: "https://fonts.googleapis.com/css2?family=Red+Hat+Display:ital,wght@0,300..900;1,300..900&display=swap",
+ rel: "stylesheet"
+ }
+ ],
+ meta: [{ charSet: "utf-8" }, { content: "width=device-width, initial-scale=1.0", name: "viewport" }, { title: "Vortex" }],
+ scripts: [{ children: GTM_SNIPPET }]
+ }),
+ shellComponent: RootDocument
+});
+
+function RootDocument({ children }: PropsWithChildren) {
+ // `lang` has to reflect the active locale for crawlers reading the prerendered HTML.
+ const { locale } = useParams({ strict: false });
-const RootComponent = () => (
-
-
-
-
-
- {/* This is where the dialogs/modals are rendered. It is placed here because it is the highest point in the app where the tailwind data-theme is available */}
-
-
-
-
-);
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
-export const Route = createRootRoute({ component: RootComponent });
+function RootComponent() {
+ return (
+
+
+ {/* This stack is ordered by dependency, not by preference — each provider's body consumes
+ the ones above it, and every one of them renders on the prerendered marketing pages too:
+ `BaseLayout` calls `useInitTokenBalances`/`useStepper` and `EventsProvider` calls
+ `useVortexAccount`, both of which reach for wagmi, the ramp actor and the Polkadot wallet
+ state. Wagmi and the ramp actor also back `NetworkProvider`'s own body. Moving any of them
+ under the widget route would throw on every marketing route until those consumers are made
+ provider-optional. Only the AppKit modal is browser-scoped (see wagmiConfig.ts). */}
+
+
+
+
+
+
+
+
+
+ {/* This is where the dialogs/modals are rendered. It is placed here because it is the highest point in the app where the tailwind data-theme is available */}
+
+ {/* The devtools read router context from their own module copy, which is empty
+ during the SSR render, so they only mount once hydrated. */}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/routes/{-$locale}/widget.tsx b/apps/frontend/src/routes/{-$locale}/widget.tsx
index 2c8824b91..1cbcfa586 100644
--- a/apps/frontend/src/routes/{-$locale}/widget.tsx
+++ b/apps/frontend/src/routes/{-$locale}/widget.tsx
@@ -7,6 +7,9 @@ import { rampSearchSchema } from "../../types/searchParams";
export const Route = createFileRoute("/{-$locale}/widget")({
component: RouteComponent,
+ // The ramp flow is driven by wallet SDKs and persisted machine state that only exist in the
+ // browser, so it renders from the SPA shell instead of being server-rendered.
+ ssr: false,
validateSearch: zodValidator(rampSearchSchema)
});
diff --git a/apps/frontend/src/services/auth.ts b/apps/frontend/src/services/auth.ts
index f9f4ca8fb..da0fba3d3 100644
--- a/apps/frontend/src/services/auth.ts
+++ b/apps/frontend/src/services/auth.ts
@@ -1,5 +1,5 @@
import * as Sentry from "@sentry/react";
-import { supabase } from "../config/supabase";
+import { getSupabaseClient } from "../config/supabase";
import { SIGNING_SERVICE_URL } from "../constants/constants";
export interface AuthTokens {
@@ -176,7 +176,7 @@ export class AuthService {
* Sign out
*/
static async signOut(): Promise {
- await supabase.auth.signOut();
+ await getSupabaseClient().auth.signOut();
this.clearTokens();
}
}
diff --git a/apps/frontend/src/services/storage/local.ts b/apps/frontend/src/services/storage/local.ts
index 67bd059af..64aff2b79 100644
--- a/apps/frontend/src/services/storage/local.ts
+++ b/apps/frontend/src/services/storage/local.ts
@@ -2,30 +2,32 @@ import { Storage } from "./types";
const exists = (value?: string | null): value is string => !!value && value.length > 0;
+// During SSR/prerender `localStorage` is an undeclared global, so `!localStorage` and
+// `localStorage?.x` both throw a ReferenceError — it has to be probed with `typeof`.
+const browserStorage: globalThis.Storage | undefined = typeof localStorage === "undefined" ? undefined : localStorage;
+
export const storageService: Storage = {
get: (key, defaultValue?) => {
- if (!localStorage) return defaultValue;
- const value = localStorage.getItem(key);
+ const value = browserStorage?.getItem(key);
return exists(value) ? value : defaultValue;
},
- getBoolean: (key: string) => Boolean(localStorage?.getItem(key)),
+ getBoolean: (key: string) => Boolean(browserStorage?.getItem(key)),
- getNumber: (key: string) => Number(localStorage?.getItem(key)),
+ getNumber: (key: string) => Number(browserStorage?.getItem(key)),
getParsed: (key, defaultValue?, parser = JSON.parse) => {
- if (!localStorage) return defaultValue;
- const value = localStorage.getItem(key);
+ const value = browserStorage?.getItem(key);
if (!exists(value)) return defaultValue;
try {
- return parser(value as string);
+ return parser(value);
} catch (_e) {
return defaultValue;
}
},
- remove: key => localStorage?.removeItem(key),
+ remove: key => browserStorage?.removeItem(key),
set: (key, value?) =>
- localStorage?.setItem(
+ browserStorage?.setItem(
key,
(value && typeof value === "object") || Array.isArray(value) ? JSON.stringify(value) : String(value)
)
diff --git a/apps/frontend/src/tests/netlify-redirects.test.ts b/apps/frontend/src/tests/netlify-redirects.test.ts
new file mode 100644
index 000000000..eb5ae1b99
--- /dev/null
+++ b/apps/frontend/src/tests/netlify-redirects.test.ts
@@ -0,0 +1,23 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+const redirects = readFileSync(new URL("../../_redirects", import.meta.url), "utf8")
+ .split("\n")
+ .map(line => line.trim())
+ .filter(line => line && !line.startsWith("#"))
+ .map(line => {
+ const [from, to, status] = line.split(/\s+/);
+ return { from, status, to };
+ });
+
+describe("Netlify redirects", () => {
+ it("routes only the lowercase Portuguese widget path through the SPA shell", () => {
+ expect(redirects.filter(rule => rule.from.startsWith("/pt-br"))).toEqual([
+ { from: "/pt-br/widget", status: "200", to: "/_shell.html" }
+ ]);
+ });
+
+ it("keeps unknown paths on the real 404 fallback", () => {
+ expect(redirects).toContainEqual({ from: "/*", status: "404", to: "/404.html" });
+ });
+});
diff --git a/apps/frontend/src/tests/ssr-safety.test.tsx b/apps/frontend/src/tests/ssr-safety.test.tsx
new file mode 100644
index 000000000..c0fb3bb90
--- /dev/null
+++ b/apps/frontend/src/tests/ssr-safety.test.tsx
@@ -0,0 +1,84 @@
+import { renderToString } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+import { Globe } from "../components/Globe";
+import { useEvmTokensLoaded } from "../hooks/useEvmTokensLoaded";
+import { storageService } from "../services/storage/local";
+
+const createAppKit = vi.fn();
+vi.mock("@reown/appkit/react", () => ({ createAppKit }));
+
+// This suite deliberately keeps the config's default "node" environment: it is the same DOM-less
+// environment the marketing routes are prerendered in. Each case guards a browser-global access
+// that crashed the prerender before it was fixed. Do not add a jsdom environment docblock here —
+// vitest scans the whole file for that pragma, so even mentioning it in a comment opts the suite
+// into a DOM and makes every assertion below vacuous.
+
+describe("storageService without a DOM", () => {
+ it("falls back to the default instead of throwing a ReferenceError", () => {
+ expect(storageService.get("anyKey", "fallback")).toBe("fallback");
+ expect(storageService.get("anyKey")).toBeUndefined();
+ });
+
+ it("returns defaults from getParsed", () => {
+ expect(storageService.getParsed("anyKey", { a: 1 })).toEqual({ a: 1 });
+ });
+
+ it("reads booleans and numbers as empty", () => {
+ expect(typeof localStorage).toBe("undefined");
+ expect(storageService.getBoolean("anyKey")).toBe(false);
+ expect(storageService.getNumber("anyKey")).toBeNaN();
+ });
+
+ it("makes writes and removals no-ops", () => {
+ expect(() => storageService.set("anyKey", "value")).not.toThrow();
+ expect(() => storageService.remove("anyKey")).not.toThrow();
+ });
+});
+
+describe("useEvmTokensLoaded without a DOM", () => {
+ it("has a server snapshot, so useSyncExternalStore can render it", () => {
+ const Probe = () => {String(useEvmTokensLoaded())};
+ expect(renderToString()).toContain("false");
+ });
+});
+
+describe("wagmiConfig without a DOM", () => {
+ it("exports the adapter config without constructing the AppKit modal", async () => {
+ expect(typeof window).toBe("undefined");
+
+ const { wagmiConfig } = await import("../wagmiConfig");
+
+ // `__root.tsx` imports this module on every prerendered marketing page. AppKit registers
+ // custom elements against browser globals, so the modal must stay behind the `window` guard.
+ expect(createAppKit).not.toHaveBeenCalled();
+ expect(wagmiConfig).toBeDefined();
+ });
+});
+
+describe("Supabase config without environment variables", () => {
+ it("can be imported for prerendering and validates only when auth is used", async () => {
+ vi.stubEnv("VITE_SUPABASE_URL", "");
+ vi.stubEnv("VITE_SUPABASE_ANON_KEY", "");
+
+ const { getSupabaseClient } = await import("../config/supabase");
+
+ expect(() => getSupabaseClient()).toThrow("Missing Supabase environment variables");
+ vi.unstubAllEnvs();
+ });
+});
+
+describe("Globe without a DOM", () => {
+ const html = renderToString();
+
+ it("renders without reading window", () => {
+ expect(html).toContain("