This page doesn't exist!
- }) - function Page() { - const context = Route.useRouteContext() - const { _splat } = Route.useParams() - const routePath = normalizePath(_splat) - const route = getStackClient(context.queryClient).router.getRoute(routePath) - - return route && route.PageComponent ?This page doesn't exist!
+ }) + + function Page() { + const context = Route.useRouteContext() + const { _splat } = Route.useParams() + const routePath = normalizePath(_splat) + const route = getStackClient(context.queryClient).router.getRoute(routePath) + + return route && route.PageComponent ?{String(error)}
-}
+const page = createReactRouterPage({ getStackClient, getQueryClient: getOrCreateQueryClient })
+export const loader = page.loader
+export const meta = page.meta
+export const ErrorBoundary = page.ErrorBoundary
+export default page.Component
diff --git a/packages/cli/src/templates/tanstack/api-route.ts.hbs b/packages/cli/src/templates/tanstack/api-route.ts.hbs
index 79aeedf1..4d6307c2 100644
--- a/packages/cli/src/templates/tanstack/api-route.ts.hbs
+++ b/packages/cli/src/templates/tanstack/api-route.ts.hbs
@@ -1,14 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"
+import { toTanStackHandlers } from "@btst/stack/tanstack"
import { handler } from "{{alias}}lib/stack"
export const Route = createFileRoute("/api/data/$")({
- server: {
- handlers: {
- GET: async ({ request }) => handler(request),
- POST: async ({ request }) => handler(request),
- PUT: async ({ request }) => handler(request),
- PATCH: async ({ request }) => handler(request),
- DELETE: async ({ request }) => handler(request),
- },
- },
+ server: { handlers: toTanStackHandlers(handler) },
})
diff --git a/packages/cli/src/templates/tanstack/pages-route.tsx.hbs b/packages/cli/src/templates/tanstack/pages-route.tsx.hbs
index 85a0c6e3..fdd96757 100644
--- a/packages/cli/src/templates/tanstack/pages-route.tsx.hbs
+++ b/packages/cli/src/templates/tanstack/pages-route.tsx.hbs
@@ -1,31 +1,8 @@
-import { createFileRoute, notFound } from "@tanstack/react-router"
-import { normalizePath } from "@btst/stack/client"
-import { getStackClient } from "{{alias}}lib/stack-client"
+import { createFileRoute } from "@tanstack/react-router"
+import { createTanStackPageOptions } from "@btst/stack/tanstack"
import { getOrCreateQueryClient } from "{{alias}}lib/query-client"
+import { getStackClient } from "{{alias}}lib/stack-client"
-export const Route = createFileRoute("/pages/$")({
- ssr: true,
- component: BtstPagesRoute,
- loader: async ({ params }) => {
- const queryClient = getOrCreateQueryClient()
- const routePath = normalizePath(params._splat)
- const route = getStackClient(queryClient).router.getRoute(routePath)
- if (!route) throw notFound()
- if (route.loader) await route.loader()
- return { meta: route.meta?.() }
- },
- head: ({ loaderData }) => {
- if (!loaderData?.meta || !Array.isArray(loaderData.meta)) {
- return { title: "No Meta", meta: [{ title: "No Meta" }] }
- }
- return { meta: loaderData.meta }
- },
-})
-
-function BtstPagesRoute() {
- const params = Route.useParams()
- const queryClient = getOrCreateQueryClient()
- const routePath = normalizePath(params._splat)
- const route = getStackClient(queryClient).router.getRoute(routePath)
- return route?.PageComponent ? ` with the
+ * stringified error — provide your own component for a production-safe
+ * error UI (use `useRouteError()` from react-router to read the error).
+ */
+ ErrorBoundary?: ComponentType;
+ /** Wraps the rendered page (inside the `HydrationBoundary`). */
+ wrapPage?: (page: ReactNode) => ReactNode;
+ /**
+ * Options passed to `dehydrate()`. Defaults to dehydrating failed queries
+ * in addition to the React Query defaults, so the client does not refetch
+ * queries that errored during SSR. Override e.g. to sanitize error
+ * payloads before they are serialized into the HTML.
+ */
+ dehydrateOptions?: DehydrateOptions;
+}
+
+/**
+ * Creates the React Router catch-all route pieces for BTST plugin routes:
+ * SSR prefetch via `route.loader()`, React Query dehydration (including
+ * failed queries), and loader-before-meta ordering.
+ *
+ * @example
+ * ```tsx
+ * // app/routes/pages/$.tsx
+ * import { createReactRouterPage } from "@btst/stack/react-router";
+ * import { getOrCreateQueryClient } from "~/lib/query-client";
+ * import { getStackClient } from "~/lib/stack-client";
+ *
+ * const page = createReactRouterPage({ getStackClient, getQueryClient: getOrCreateQueryClient });
+ * export const loader = page.loader;
+ * export const meta = page.meta;
+ * export default page.Component;
+ * ```
+ */
+export function createReactRouterPage(options: CreateReactRouterPageOptions) {
+ const {
+ getStackClient,
+ getQueryClient,
+ NotFound,
+ ErrorBoundary: CustomErrorBoundary,
+ wrapPage,
+ dehydrateOptions = stackDehydrateOptions,
+ } = options;
+
+ async function loader({ params }: ReactRouterPageLoaderArgs) {
+ const queryClient = getQueryClient();
+ const path = normalizePath(params["*"]);
+ const route = getStackClient(queryClient).router.getRoute(path);
+
+ if (route?.loader) {
+ await route.loader();
+ }
+
+ return {
+ path,
+ dehydratedState: dehydrate(queryClient, dehydrateOptions),
+ meta: await route?.meta?.(),
+ };
+ }
+
+ type LoaderData = Awaited>;
+
+ // Recent React Router versions pass `loaderData`; older 7.x used `data`.
+ function meta(args: { loaderData?: LoaderData; data?: LoaderData }) {
+ return (args.loaderData ?? args.data)?.meta;
+ }
+
+ function Component() {
+ const data = useLoaderData();
+ const queryClient = useQueryClient();
+ const route = getStackClient(queryClient).router.getRoute(data.path);
+ const page = route?.PageComponent ? (
+
+ ) : NotFound ? (
+
+ ) : (
+ Route not found
+ );
+
+ return (
+
+ {wrapPage ? wrapPage(page) : page}
+
+ );
+ }
+
+ function DefaultErrorBoundary() {
+ const error = useRouteError();
+ return {String(error)};
+ }
+
+ return {
+ loader,
+ meta,
+ Component,
+ ErrorBoundary: CustomErrorBoundary ?? DefaultErrorBoundary,
+ };
+}
diff --git a/packages/stack/src/react-router/router.tsx b/packages/stack/src/react-router/router.tsx
new file mode 100644
index 00000000..27b0b40f
--- /dev/null
+++ b/packages/stack/src/react-router/router.tsx
@@ -0,0 +1,63 @@
+"use client";
+import { useMemo } from "react";
+import {
+ Link as ReactRouterLink,
+ useNavigate,
+ useRevalidator,
+ useSearchParams,
+} from "react-router";
+import type { StackRouter, StackRouterConfig } from "../context/router";
+
+function ReactRouterLinkWrapper({
+ href,
+ children,
+ ...props
+}: React.ComponentProps<"a"> & Record) {
+ return (
+
+ {children}
+
+ );
+}
+
+function useReactRouterStackRouter(): StackRouter {
+ const navigate = useNavigate();
+ const { revalidate } = useRevalidator();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ return useMemo(
+ () => ({
+ navigate: (path: string) => {
+ void navigate(path);
+ },
+ refresh: () => {
+ void revalidate();
+ },
+ getSearchParams: () => new URLSearchParams(searchParams),
+ setSearchParams: (
+ next: URLSearchParams,
+ opts?: { replace?: boolean },
+ ) => {
+ setSearchParams(next, { replace: opts?.replace });
+ },
+ }),
+ [navigate, revalidate, searchParams, setSearchParams],
+ );
+}
+
+/**
+ * Router preset for React Router (v7).
+ *
+ * @example
+ * ```tsx
+ * import { reactRouter } from "@btst/stack/react-router";
+ *
+ *
+ * ```
+ */
+export function reactRouter(): StackRouterConfig {
+ return {
+ Link: ReactRouterLinkWrapper,
+ useRouter: useReactRouterStackRouter,
+ };
+}
diff --git a/packages/stack/src/shared/entry-factories.ts b/packages/stack/src/shared/entry-factories.ts
new file mode 100644
index 00000000..837c4c65
--- /dev/null
+++ b/packages/stack/src/shared/entry-factories.ts
@@ -0,0 +1,55 @@
+import {
+ type DehydrateOptions,
+ type QueryClient,
+ defaultShouldDehydrateQuery,
+} from "@tanstack/react-query";
+import type { ComponentType } from "react";
+
+/**
+ * Minimal structural view of a route returned by
+ * `createStackClient(...).router.getRoute(path)`. The framework entry
+ * factories only need these three fields.
+ */
+export interface StackRouteLike {
+ PageComponent?: ComponentType | undefined;
+ loader?: ((...args: any[]) => unknown) | undefined;
+ meta?: ((...args: any[]) => any) | undefined;
+}
+
+/**
+ * Minimal structural view of the object returned by `createStackClient`.
+ * Any concrete `ClientLib` is assignable to this shape.
+ */
+export interface StackClientLike {
+ router: {
+ getRoute: (
+ path: string,
+ queryParams?: Record,
+ ) => (StackRouteLike & Record) | null | undefined;
+ };
+}
+
+/**
+ * Consumer-provided factory that returns the stack client for a given
+ * QueryClient (per-request on the server, singleton on the client).
+ */
+export type GetStackClient = (queryClient: QueryClient) => StackClientLike;
+
+/**
+ * A framework-agnostic BTST API handler, as returned by
+ * `createBackendHandler(...).handler`.
+ */
+export type StackRequestHandler = (
+ request: Request,
+) => Response | Promise;
+
+/**
+ * Dehydration config owned by the page factories: dehydrate everything the
+ * default would, plus failed queries, so the client does not refetch (and
+ * flash a loading state) for queries that errored during SSR — regardless of
+ * how the consumer configured their QueryClient.
+ */
+export const stackDehydrateOptions: DehydrateOptions = {
+ shouldDehydrateQuery: (query) =>
+ defaultShouldDehydrateQuery(query) || query.state.status === "error",
+};
diff --git a/packages/stack/src/tanstack/handlers.ts b/packages/stack/src/tanstack/handlers.ts
new file mode 100644
index 00000000..5445c7af
--- /dev/null
+++ b/packages/stack/src/tanstack/handlers.ts
@@ -0,0 +1,28 @@
+import type { StackRequestHandler } from "../shared/entry-factories";
+
+/**
+ * Wires a BTST API handler to the method handler map a TanStack Start server
+ * route needs.
+ *
+ * @example
+ * ```ts
+ * // src/routes/api/data/$.ts
+ * import { createFileRoute } from "@tanstack/react-router";
+ * import { toTanStackHandlers } from "@btst/stack/tanstack";
+ * import { handler } from "@/lib/stack";
+ *
+ * export const Route = createFileRoute("/api/data/$")({
+ * server: { handlers: toTanStackHandlers(handler) },
+ * });
+ * ```
+ */
+export function toTanStackHandlers(handler: StackRequestHandler) {
+ const methodHandler = ({ request }: { request: Request }) => handler(request);
+ return {
+ GET: methodHandler,
+ POST: methodHandler,
+ PUT: methodHandler,
+ PATCH: methodHandler,
+ DELETE: methodHandler,
+ };
+}
diff --git a/packages/stack/src/tanstack/index.tsx b/packages/stack/src/tanstack/index.tsx
index 9451bf42..f91f0066 100644
--- a/packages/stack/src/tanstack/index.tsx
+++ b/packages/stack/src/tanstack/index.tsx
@@ -1,61 +1,10 @@
-"use client";
-import { Link as TanStackLink, useRouter } from "@tanstack/react-router";
-import { useMemo } from "react";
-import type { StackRouter, StackRouterConfig } from "../context/router";
-
-function TanStackLinkWrapper({
- href,
- children,
- ...props
-}: React.ComponentProps<"a"> & Record) {
- return (
-
- {children}
-
- );
-}
-
-function useTanStackStackRouter(): StackRouter {
- const router = useRouter();
-
- return useMemo(
- () => ({
- navigate: (path: string) => {
- router.navigate({ href: path });
- },
- refresh: () => {
- router.invalidate();
- },
- getSearchParams: () =>
- new URLSearchParams(router.state.location.searchStr ?? ""),
- setSearchParams: (
- next: URLSearchParams,
- opts?: { replace?: boolean },
- ) => {
- const query = next.toString();
- router.navigate({
- href: `${router.state.location.pathname}${query ? `?${query}` : ""}`,
- replace: opts?.replace,
- });
- },
- }),
- [router],
- );
-}
-
-/**
- * Router preset for TanStack Router / TanStack Start.
- *
- * @example
- * ```tsx
- * import { tanstackRouter } from "@btst/stack/tanstack";
- *
- *
- * ```
- */
-export function tanstackRouter(): StackRouterConfig {
- return {
- Link: TanStackLinkWrapper,
- useRouter: useTanStackStackRouter,
- };
-}
+export { toTanStackHandlers } from "./handlers";
+export {
+ type CreateTanStackPageOptions,
+ createTanStackPageOptions,
+} from "./page";
+export { tanstackRouter } from "./router";
+export type {
+ GetStackClient,
+ StackRequestHandler,
+} from "../shared/entry-factories";
diff --git a/packages/stack/src/tanstack/page.tsx b/packages/stack/src/tanstack/page.tsx
new file mode 100644
index 00000000..0cd9e164
--- /dev/null
+++ b/packages/stack/src/tanstack/page.tsx
@@ -0,0 +1,88 @@
+import type { QueryClient } from "@tanstack/react-query";
+import { notFound, useParams, useRouteContext } from "@tanstack/react-router";
+import { normalizePath } from "../client/path-utils";
+import type { GetStackClient } from "../shared/entry-factories";
+
+export interface CreateTanStackPageOptions {
+ /** Returns the stack client for a given QueryClient (`lib/stack-client`). */
+ getStackClient: GetStackClient;
+ /**
+ * Returns the QueryClient for the current context. Defaults to the
+ * `queryClient` from the router context (set up by
+ * `setupRouterSsrQueryIntegration`).
+ */
+ getQueryClient?: () => QueryClient;
+}
+
+interface TanStackPageLoaderArgs {
+ params: { _splat?: string };
+ context?: unknown;
+}
+
+/**
+ * Creates the route options for the TanStack Start catch-all page, to spread
+ * into `createFileRoute("/pages/$")(...)`: SSR prefetch via `route.loader()`,
+ * head/meta from `route.meta()` with loader-before-meta ordering, and 404 via
+ * `notFound()`. Query cache dehydration is handled by TanStack's router-query
+ * SSR integration.
+ *
+ * @example
+ * ```tsx
+ * // src/routes/pages/$.tsx
+ * import { createFileRoute } from "@tanstack/react-router";
+ * import { createTanStackPageOptions } from "@btst/stack/tanstack";
+ * import { getStackClient } from "@/lib/stack-client";
+ *
+ * export const Route = createFileRoute("/pages/$")(
+ * createTanStackPageOptions({ getStackClient }),
+ * );
+ * ```
+ */
+export function createTanStackPageOptions(options: CreateTanStackPageOptions) {
+ const { getStackClient, getQueryClient } = options;
+
+ function resolveQueryClient(context: unknown): QueryClient {
+ const fromContext = (context as { queryClient?: QueryClient } | null)
+ ?.queryClient;
+ const queryClient = getQueryClient?.() ?? fromContext;
+ if (!queryClient) {
+ throw new Error(
+ "createTanStackPageOptions: no QueryClient available. Provide `getQueryClient` or add `queryClient` to the router context.",
+ );
+ }
+ return queryClient;
+ }
+
+ function PageComponent() {
+ const params = useParams({ strict: false }) as { _splat?: string };
+ const context = useRouteContext({ strict: false });
+ const routePath = normalizePath(params._splat);
+ const route = getStackClient(resolveQueryClient(context)).router.getRoute(
+ routePath,
+ );
+ return route?.PageComponent ? (
+
+ ) : (
+ Route not found
+ );
+ }
+
+ return {
+ ssr: true,
+ component: PageComponent,
+ loader: async ({ params, context }: TanStackPageLoaderArgs) => {
+ const queryClient = resolveQueryClient(context);
+ const routePath = normalizePath(params._splat);
+ const route = getStackClient(queryClient).router.getRoute(routePath);
+ if (!route) throw notFound();
+ if (route.loader) await route.loader();
+ return { meta: await route.meta?.() };
+ },
+ head: ({ loaderData }: { loaderData?: { meta?: unknown } }) => {
+ if (!loaderData?.meta || !Array.isArray(loaderData.meta)) {
+ return { title: "No Meta", meta: [{ title: "No Meta" }] };
+ }
+ return { meta: loaderData.meta };
+ },
+ } as const;
+}
diff --git a/packages/stack/src/tanstack/router.tsx b/packages/stack/src/tanstack/router.tsx
new file mode 100644
index 00000000..9451bf42
--- /dev/null
+++ b/packages/stack/src/tanstack/router.tsx
@@ -0,0 +1,61 @@
+"use client";
+import { Link as TanStackLink, useRouter } from "@tanstack/react-router";
+import { useMemo } from "react";
+import type { StackRouter, StackRouterConfig } from "../context/router";
+
+function TanStackLinkWrapper({
+ href,
+ children,
+ ...props
+}: React.ComponentProps<"a"> & Record) {
+ return (
+
+ {children}
+
+ );
+}
+
+function useTanStackStackRouter(): StackRouter {
+ const router = useRouter();
+
+ return useMemo(
+ () => ({
+ navigate: (path: string) => {
+ router.navigate({ href: path });
+ },
+ refresh: () => {
+ router.invalidate();
+ },
+ getSearchParams: () =>
+ new URLSearchParams(router.state.location.searchStr ?? ""),
+ setSearchParams: (
+ next: URLSearchParams,
+ opts?: { replace?: boolean },
+ ) => {
+ const query = next.toString();
+ router.navigate({
+ href: `${router.state.location.pathname}${query ? `?${query}` : ""}`,
+ replace: opts?.replace,
+ });
+ },
+ }),
+ [router],
+ );
+}
+
+/**
+ * Router preset for TanStack Router / TanStack Start.
+ *
+ * @example
+ * ```tsx
+ * import { tanstackRouter } from "@btst/stack/tanstack";
+ *
+ *
+ * ```
+ */
+export function tanstackRouter(): StackRouterConfig {
+ return {
+ Link: TanStackLinkWrapper,
+ useRouter: useTanStackStackRouter,
+ };
+}
diff --git a/scripts/codegen/files/tanstack/src/routes/pages/$.tsx b/scripts/codegen/files/tanstack/src/routes/pages/$.tsx
index 48cad060..df282181 100644
--- a/scripts/codegen/files/tanstack/src/routes/pages/$.tsx
+++ b/scripts/codegen/files/tanstack/src/routes/pages/$.tsx
@@ -1,34 +1,7 @@
-import { createFileRoute, notFound } from "@tanstack/react-router";
-import { normalizePath } from "@btst/stack/client";
+import { createTanStackPageOptions } from "@btst/stack/tanstack";
+import { createFileRoute } from "@tanstack/react-router";
import { getStackClient } from "@/lib/stack-client";
-export const Route = createFileRoute("/pages/$")({
- ssr: true,
- component: BtstPagesRoute,
- loader: async ({ params, context }) => {
- const queryClient = context.queryClient;
- const routePath = normalizePath(params._splat);
- const route = getStackClient(queryClient).router.getRoute(routePath);
- if (!route) throw notFound();
- if (route.loader) await route.loader();
- return { meta: route.meta?.() };
- },
- head: ({ loaderData }) => {
- if (!loaderData?.meta || !Array.isArray(loaderData.meta)) {
- return { title: "No Meta", meta: [{ title: "No Meta" }] };
- }
- return { meta: loaderData.meta };
- },
-});
-
-function BtstPagesRoute() {
- const params = Route.useParams();
- const { queryClient } = Route.useRouteContext();
- const routePath = normalizePath(params._splat);
- const route = getStackClient(queryClient).router.getRoute(routePath);
- return route?.PageComponent ? (
-
- ) : (
- Route not found
- );
-}
+export const Route = createFileRoute("/pages/$")(
+ createTanStackPageOptions({ getStackClient }),
+);