@btst/stack 2.9.2 → 2.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/packages/stack/src/plugins/blog/client/plugin.cjs +22 -11
  2. package/dist/packages/stack/src/plugins/blog/client/plugin.mjs +22 -11
  3. package/dist/packages/stack/src/plugins/cms/client/plugin.cjs +71 -39
  4. package/dist/packages/stack/src/plugins/cms/client/plugin.mjs +71 -39
  5. package/dist/packages/stack/src/plugins/comments/client/plugin.cjs +62 -2
  6. package/dist/packages/stack/src/plugins/comments/client/plugin.mjs +63 -3
  7. package/dist/packages/stack/src/plugins/comments/client/utils.cjs +2 -11
  8. package/dist/packages/stack/src/plugins/comments/client/utils.mjs +2 -11
  9. package/dist/packages/stack/src/plugins/comments/error-utils.cjs +15 -0
  10. package/dist/packages/stack/src/plugins/comments/error-utils.mjs +13 -0
  11. package/dist/packages/stack/src/plugins/form-builder/client/plugin.cjs +59 -31
  12. package/dist/packages/stack/src/plugins/form-builder/client/plugin.mjs +59 -31
  13. package/dist/packages/stack/src/plugins/media/db.cjs +10 -2
  14. package/dist/packages/stack/src/plugins/media/db.mjs +10 -2
  15. package/dist/packages/stack/src/plugins/ui-builder/client/plugin.cjs +52 -25
  16. package/dist/packages/stack/src/plugins/ui-builder/client/plugin.mjs +53 -26
  17. package/dist/packages/stack/src/plugins/utils.cjs +6 -0
  18. package/dist/packages/stack/src/plugins/utils.mjs +5 -1
  19. package/dist/plugins/client/index.cjs +2 -0
  20. package/dist/plugins/client/index.d.cts +15 -1
  21. package/dist/plugins/client/index.d.mts +15 -1
  22. package/dist/plugins/client/index.d.ts +15 -1
  23. package/dist/plugins/client/index.mjs +1 -1
  24. package/dist/plugins/comments/client/index.d.cts +5 -0
  25. package/dist/plugins/comments/client/index.d.mts +5 -0
  26. package/dist/plugins/comments/client/index.d.ts +5 -0
  27. package/dist/plugins/comments/query-keys.cjs +4 -4
  28. package/dist/plugins/comments/query-keys.mjs +1 -1
  29. package/package.json +1 -1
  30. package/src/__tests__/client-plugin-ssr-loaders.test.ts +329 -0
  31. package/src/plugins/blog/client/plugin.tsx +23 -14
  32. package/src/plugins/client/index.ts +2 -0
  33. package/src/plugins/cms/client/plugin.tsx +73 -42
  34. package/src/plugins/comments/client/plugin.tsx +82 -2
  35. package/src/plugins/comments/client/utils.ts +2 -14
  36. package/src/plugins/comments/error-utils.ts +17 -0
  37. package/src/plugins/comments/query-keys.ts +1 -1
  38. package/src/plugins/form-builder/client/plugin.tsx +59 -35
  39. package/src/plugins/media/__tests__/plugin.test.ts +4 -4
  40. package/src/plugins/media/db.ts +8 -0
  41. package/src/plugins/ui-builder/client/plugin.tsx +57 -27
  42. package/src/plugins/utils.ts +18 -0
@@ -2,10 +2,14 @@
2
2
  import { lazy } from "react";
3
3
  import {
4
4
  defineClientPlugin,
5
+ createApiClient,
5
6
  isConnectionError,
6
7
  } from "@btst/stack/plugins/client";
7
8
  import { createRoute } from "@btst/yar";
8
9
  import type { QueryClient } from "@tanstack/react-query";
10
+ import type { CommentsApiRouter } from "../api";
11
+ import { createCommentsQueryKeys } from "../query-keys";
12
+ import { createSanitizedSSRLoaderError } from "../../utils";
9
13
 
10
14
  // Lazy load page components for code splitting
11
15
  const ModerationPageComponent = lazy(() =>
@@ -36,6 +40,11 @@ export interface LoaderContext {
36
40
  apiBasePath: string;
37
41
  /** Optional headers for the request */
38
42
  headers?: Headers;
43
+ /**
44
+ * Optional current user ID for SSR loaders that need user-scoped query keys.
45
+ * Hooks (e.g. beforeLoadUserComments) may populate this.
46
+ */
47
+ currentUserId?: string;
39
48
  /** Additional context properties */
40
49
  [key: string]: unknown;
41
50
  }
@@ -81,7 +90,7 @@ export interface CommentsClientConfig {
81
90
  function createModerationLoader(config: CommentsClientConfig) {
82
91
  return async () => {
83
92
  if (typeof window === "undefined") {
84
- const { apiBasePath, apiBaseURL, headers, hooks } = config;
93
+ const { queryClient, apiBasePath, apiBaseURL, headers, hooks } = config;
85
94
  const context: LoaderContext = {
86
95
  path: "/comments/moderation",
87
96
  isSSR: true,
@@ -89,15 +98,43 @@ function createModerationLoader(config: CommentsClientConfig) {
89
98
  apiBasePath,
90
99
  headers,
91
100
  };
101
+ const client = createApiClient<CommentsApiRouter>({
102
+ baseURL: apiBaseURL,
103
+ basePath: apiBasePath,
104
+ });
105
+ const queries = createCommentsQueryKeys(client, headers);
106
+ const listQuery = queries.comments.list({
107
+ status: "pending",
108
+ limit: 20,
109
+ offset: 0,
110
+ });
92
111
  try {
93
112
  if (hooks?.beforeLoadModeration) {
94
113
  await hooks.beforeLoadModeration(context);
95
114
  }
115
+ await queryClient.prefetchQuery(listQuery);
116
+ const queryState = queryClient.getQueryState(listQuery.queryKey);
117
+ if (queryState?.error && hooks?.onLoadError) {
118
+ const error =
119
+ queryState.error instanceof Error
120
+ ? queryState.error
121
+ : new Error(String(queryState.error));
122
+ await hooks.onLoadError(error, context);
123
+ }
96
124
  } catch (error) {
97
125
  if (isConnectionError(error)) {
98
126
  console.warn(
99
127
  "[btst/comments] route.loader() failed — no server running at build time.",
100
128
  );
129
+ } else {
130
+ const errToStore = createSanitizedSSRLoaderError();
131
+ await queryClient.prefetchQuery({
132
+ queryKey: listQuery.queryKey,
133
+ queryFn: () => {
134
+ throw errToStore;
135
+ },
136
+ retry: false,
137
+ });
101
138
  }
102
139
  if (hooks?.onLoadError) {
103
140
  await hooks.onLoadError(error as Error, context);
@@ -110,7 +147,7 @@ function createModerationLoader(config: CommentsClientConfig) {
110
147
  function createUserCommentsLoader(config: CommentsClientConfig) {
111
148
  return async () => {
112
149
  if (typeof window === "undefined") {
113
- const { apiBasePath, apiBaseURL, headers, hooks } = config;
150
+ const { queryClient, apiBasePath, apiBaseURL, headers, hooks } = config;
114
151
  const context: LoaderContext = {
115
152
  path: "/comments",
116
153
  isSSR: true,
@@ -118,15 +155,58 @@ function createUserCommentsLoader(config: CommentsClientConfig) {
118
155
  apiBasePath,
119
156
  headers,
120
157
  };
158
+ const client = createApiClient<CommentsApiRouter>({
159
+ baseURL: apiBaseURL,
160
+ basePath: apiBasePath,
161
+ });
162
+ const queries = createCommentsQueryKeys(client, headers);
163
+ const getUserListQuery = (currentUserId: string) =>
164
+ queries.comments.list({
165
+ authorId: currentUserId,
166
+ sort: "desc",
167
+ limit: 20,
168
+ offset: 0,
169
+ });
121
170
  try {
122
171
  if (hooks?.beforeLoadUserComments) {
123
172
  await hooks.beforeLoadUserComments(context);
124
173
  }
174
+ const currentUserId =
175
+ typeof context.currentUserId === "string"
176
+ ? context.currentUserId
177
+ : undefined;
178
+ if (currentUserId) {
179
+ const listQuery = getUserListQuery(currentUserId);
180
+ await queryClient.prefetchQuery(listQuery);
181
+ const queryState = queryClient.getQueryState(listQuery.queryKey);
182
+ if (queryState?.error && hooks?.onLoadError) {
183
+ const error =
184
+ queryState.error instanceof Error
185
+ ? queryState.error
186
+ : new Error(String(queryState.error));
187
+ await hooks.onLoadError(error, context);
188
+ }
189
+ }
125
190
  } catch (error) {
126
191
  if (isConnectionError(error)) {
127
192
  console.warn(
128
193
  "[btst/comments] route.loader() failed — no server running at build time.",
129
194
  );
195
+ } else {
196
+ const currentUserId =
197
+ typeof context.currentUserId === "string"
198
+ ? context.currentUserId
199
+ : undefined;
200
+ if (currentUserId) {
201
+ const errToStore = createSanitizedSSRLoaderError();
202
+ await queryClient.prefetchQuery({
203
+ queryKey: getUserListQuery(currentUserId).queryKey,
204
+ queryFn: () => {
205
+ throw errToStore;
206
+ },
207
+ retry: false,
208
+ });
209
+ }
130
210
  }
131
211
  if (hooks?.onLoadError) {
132
212
  await hooks.onLoadError(error as Error, context);
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect } from "react";
2
2
  import type { CommentsPluginOverrides } from "./overrides";
3
+ import { toError as toErrorShared } from "../error-utils";
3
4
 
4
5
  /**
5
6
  * Resolves `currentUserId` from the plugin overrides, supporting both a static
@@ -40,20 +41,7 @@ export function useResolvedCurrentUserId(
40
41
  * copied onto the Error via Object.assign so callers can inspect them.
41
42
  * 3. Anything else — converted via String().
42
43
  */
43
- export function toError(error: unknown): Error {
44
- if (error instanceof Error) return error;
45
- if (typeof error === "object" && error !== null) {
46
- const obj = error as Record<string, unknown>;
47
- const message =
48
- (typeof obj.message === "string" ? obj.message : null) ||
49
- (typeof obj.error === "string" ? obj.error : null) ||
50
- JSON.stringify(error);
51
- const err = new Error(message);
52
- Object.assign(err, error);
53
- return err;
54
- }
55
- return new Error(String(error));
56
- }
44
+ export const toError = toErrorShared;
57
45
 
58
46
  export function getInitials(name: string | null | undefined): string {
59
47
  if (!name) return "?";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Normalize any thrown value into an Error.
3
+ */
4
+ export function toError(error: unknown): Error {
5
+ if (error instanceof Error) return error;
6
+ if (typeof error === "object" && error !== null) {
7
+ const obj = error as Record<string, unknown>;
8
+ const message =
9
+ (typeof obj.message === "string" ? obj.message : null) ||
10
+ (typeof obj.error === "string" ? obj.error : null) ||
11
+ JSON.stringify(error);
12
+ const err = new Error(message);
13
+ Object.assign(err, error);
14
+ return err;
15
+ }
16
+ return new Error(String(error));
17
+ }
@@ -10,7 +10,7 @@ import {
10
10
  commentCountDiscriminator,
11
11
  commentsThreadDiscriminator,
12
12
  } from "./api/query-key-defs";
13
- import { toError } from "./client/utils";
13
+ import { toError } from "./error-utils";
14
14
 
15
15
  interface CommentsListParams {
16
16
  resourceId?: string;
@@ -9,6 +9,7 @@ import {
9
9
  import { createRoute } from "@btst/yar";
10
10
  import type { ComponentType } from "react";
11
11
  import type { QueryClient } from "@tanstack/react-query";
12
+ import { createSanitizedSSRLoaderError } from "../../utils";
12
13
  import type { FormBuilderApiRouter } from "../api";
13
14
  import { createFormBuilderQueryKeys } from "../query-keys";
14
15
 
@@ -160,6 +161,13 @@ function createFormListLoader(config: FormBuilderClientConfig) {
160
161
  apiBasePath,
161
162
  headers,
162
163
  };
164
+ const client = createApiClient<FormBuilderApiRouter>({
165
+ baseURL: apiBaseURL,
166
+ basePath: apiBasePath,
167
+ });
168
+ const queries = createFormBuilderQueryKeys(client, headers);
169
+ const limit = 20;
170
+ const listQuery = queries.forms.list({ limit, offset: 0 });
163
171
 
164
172
  try {
165
173
  // Before hook - authorization check
@@ -170,15 +178,7 @@ function createFormListLoader(config: FormBuilderClientConfig) {
170
178
  );
171
179
  }
172
180
 
173
- const client = createApiClient<FormBuilderApiRouter>({
174
- baseURL: apiBaseURL,
175
- basePath: apiBasePath,
176
- });
177
- const queries = createFormBuilderQueryKeys(client, headers);
178
- const limit = 20;
179
-
180
181
  // Prefetch forms using infinite query
181
- const listQuery = queries.forms.list({ limit, offset: 0 });
182
182
  await queryClient.prefetchInfiniteQuery({
183
183
  queryKey: listQuery.queryKey,
184
184
  queryFn: async ({ pageParam = 0 }) => {
@@ -221,6 +221,16 @@ function createFormListLoader(config: FormBuilderClientConfig) {
221
221
  "[btst/form-builder] route.loader() failed — no server running at build time. " +
222
222
  "Use myStack.api.formBuilder.prefetchForRoute() for SSG data prefetching.",
223
223
  );
224
+ } else {
225
+ const errToStore = createSanitizedSSRLoaderError();
226
+ await queryClient.prefetchInfiniteQuery({
227
+ queryKey: listQuery.queryKey,
228
+ queryFn: () => {
229
+ throw errToStore;
230
+ },
231
+ initialPageParam: 0,
232
+ retry: false,
233
+ });
224
234
  }
225
235
  if (hooks?.onLoadError) {
226
236
  await hooks.onLoadError(error as Error, context);
@@ -249,6 +259,12 @@ function createFormBuilderLoader(
249
259
  apiBasePath,
250
260
  headers,
251
261
  };
262
+ const client = createApiClient<FormBuilderApiRouter>({
263
+ baseURL: apiBaseURL,
264
+ basePath: apiBasePath,
265
+ });
266
+ const queries = createFormBuilderQueryKeys(client, headers);
267
+ const formQuery = id ? queries.forms.byId(id) : undefined;
252
268
 
253
269
  try {
254
270
  // Before hook - authorization check
@@ -259,15 +275,9 @@ function createFormBuilderLoader(
259
275
  );
260
276
  }
261
277
 
262
- const client = createApiClient<FormBuilderApiRouter>({
263
- baseURL: apiBaseURL,
264
- basePath: apiBasePath,
265
- });
266
- const queries = createFormBuilderQueryKeys(client, headers);
267
-
268
278
  // Prefetch form if editing
269
279
  if (id) {
270
- await queryClient.prefetchQuery(queries.forms.byId(id));
280
+ await queryClient.prefetchQuery(formQuery!);
271
281
  }
272
282
 
273
283
  // After hook
@@ -277,9 +287,7 @@ function createFormBuilderLoader(
277
287
 
278
288
  // Check if there was an error
279
289
  if (id) {
280
- const queryState = queryClient.getQueryState(
281
- queries.forms.byId(id).queryKey,
282
- );
290
+ const queryState = queryClient.getQueryState(formQuery!.queryKey);
283
291
  if (queryState?.error && hooks?.onLoadError) {
284
292
  const error =
285
293
  queryState.error instanceof Error
@@ -295,6 +303,15 @@ function createFormBuilderLoader(
295
303
  "[btst/form-builder] route.loader() failed — no server running at build time. " +
296
304
  "Use myStack.api.formBuilder.prefetchForRoute() for SSG data prefetching.",
297
305
  );
306
+ } else if (formQuery) {
307
+ const errToStore = createSanitizedSSRLoaderError();
308
+ await queryClient.prefetchQuery({
309
+ queryKey: formQuery.queryKey,
310
+ queryFn: () => {
311
+ throw errToStore;
312
+ },
313
+ retry: false,
314
+ });
298
315
  }
299
316
  if (hooks?.onLoadError) {
300
317
  await hooks.onLoadError(error as Error, context);
@@ -323,6 +340,18 @@ function createSubmissionsLoader(
323
340
  apiBasePath,
324
341
  headers,
325
342
  };
343
+ const client = createApiClient<FormBuilderApiRouter>({
344
+ baseURL: apiBaseURL,
345
+ basePath: apiBasePath,
346
+ });
347
+ const queries = createFormBuilderQueryKeys(client, headers);
348
+ const limit = 20;
349
+ const formQuery = queries.forms.byId(formId);
350
+ const submissionsQuery = queries.formSubmissions.list({
351
+ formId,
352
+ limit,
353
+ offset: 0,
354
+ });
326
355
 
327
356
  try {
328
357
  // Before hook - authorization check
@@ -333,21 +362,8 @@ function createSubmissionsLoader(
333
362
  );
334
363
  }
335
364
 
336
- const client = createApiClient<FormBuilderApiRouter>({
337
- baseURL: apiBaseURL,
338
- basePath: apiBasePath,
339
- });
340
- const queries = createFormBuilderQueryKeys(client, headers);
341
- const limit = 20;
342
-
343
365
  // Prefetch form and submissions
344
- await queryClient.prefetchQuery(queries.forms.byId(formId));
345
-
346
- const submissionsQuery = queries.formSubmissions.list({
347
- formId,
348
- limit,
349
- offset: 0,
350
- });
366
+ await queryClient.prefetchQuery(formQuery);
351
367
  await queryClient.prefetchInfiniteQuery({
352
368
  queryKey: submissionsQuery.queryKey,
353
369
  queryFn: async ({ pageParam = 0 }) => {
@@ -379,9 +395,7 @@ function createSubmissionsLoader(
379
395
  }
380
396
 
381
397
  // Check if there was an error
382
- const formState = queryClient.getQueryState(
383
- queries.forms.byId(formId).queryKey,
384
- );
398
+ const formState = queryClient.getQueryState(formQuery.queryKey);
385
399
  const submissionsState = queryClient.getQueryState(
386
400
  submissionsQuery.queryKey,
387
401
  );
@@ -400,6 +414,16 @@ function createSubmissionsLoader(
400
414
  "[btst/form-builder] route.loader() failed — no server running at build time. " +
401
415
  "Use myStack.api.formBuilder.prefetchForRoute() for SSG data prefetching.",
402
416
  );
417
+ } else {
418
+ const errToStore = createSanitizedSSRLoaderError();
419
+ await queryClient.prefetchInfiniteQuery({
420
+ queryKey: submissionsQuery.queryKey,
421
+ queryFn: () => {
422
+ throw errToStore;
423
+ },
424
+ initialPageParam: 0,
425
+ retry: false,
426
+ });
403
427
  }
404
428
  if (hooks?.onLoadError) {
405
429
  await hooks.onLoadError(error as Error, context);
@@ -84,14 +84,14 @@ function createVercelBlobStorageAdapter(
84
84
  return {
85
85
  type: "vercel-blob",
86
86
  urlHostnameSuffix: ".public.blob.vercel-storage.com",
87
- handleRequest: vi.fn(async (request, callbacks) => {
88
- const body = (await request.json()) as {
87
+ handleRequest: vi.fn(async (request, body, callbacks) => {
88
+ const parsedBody = (body ?? ((await request.json()) as unknown)) as {
89
89
  pathname?: string;
90
90
  clientPayload?: string | null;
91
91
  };
92
92
  const tokenOptions = await callbacks.onBeforeGenerateToken?.(
93
- body.pathname ?? "photo.jpg",
94
- body.clientPayload ?? null,
93
+ parsedBody.pathname ?? "photo.jpg",
94
+ parsedBody.clientPayload ?? null,
95
95
  );
96
96
  return { ok: true, tokenOptions };
97
97
  }),
@@ -31,6 +31,10 @@ export const mediaSchema = createDbPlugin("media", {
31
31
  folderId: {
32
32
  type: "string",
33
33
  required: false,
34
+ references: {
35
+ model: "mediaFolder",
36
+ field: "id",
37
+ },
34
38
  },
35
39
  alt: {
36
40
  type: "string",
@@ -52,6 +56,10 @@ export const mediaSchema = createDbPlugin("media", {
52
56
  parentId: {
53
57
  type: "string",
54
58
  required: false,
59
+ references: {
60
+ model: "mediaFolder",
61
+ field: "id",
62
+ },
55
63
  },
56
64
  createdAt: {
57
65
  type: "date",
@@ -3,6 +3,7 @@ import { lazy } from "react";
3
3
  import {
4
4
  defineClientPlugin,
5
5
  createApiClient,
6
+ isConnectionError,
6
7
  runClientHookWithShim,
7
8
  } from "@btst/stack/plugins/client";
8
9
  import { createRoute } from "@btst/yar";
@@ -10,6 +11,7 @@ import type { ComponentType } from "react";
10
11
  import type { QueryClient } from "@tanstack/react-query";
11
12
  import type { CMSApiRouter } from "../../cms/api";
12
13
  import { createCMSQueryKeys } from "../../cms/query-keys";
14
+ import { createSanitizedSSRLoaderError } from "../../utils";
13
15
  import { UI_BUILDER_TYPE_SLUG } from "../schemas";
14
16
  import type {
15
17
  UIBuilderClientHooks,
@@ -81,6 +83,18 @@ function createPageListLoader(config: UIBuilderClientConfig) {
81
83
  apiBasePath,
82
84
  headers,
83
85
  };
86
+ const client = createApiClient<CMSApiRouter>({
87
+ baseURL: apiBaseURL,
88
+ basePath: apiBasePath,
89
+ });
90
+ const queries = createCMSQueryKeys(client, headers);
91
+ const limit = 20;
92
+ const listQuery = queries.cmsContent.list({
93
+ typeSlug,
94
+ limit,
95
+ offset: 0,
96
+ });
97
+ const uiBuilderListQueryKey = [...listQuery.queryKey, "ui-builder"];
84
98
 
85
99
  try {
86
100
  // Before hook - authorization check
@@ -91,21 +105,9 @@ function createPageListLoader(config: UIBuilderClientConfig) {
91
105
  );
92
106
  }
93
107
 
94
- const client = createApiClient<CMSApiRouter>({
95
- baseURL: apiBaseURL,
96
- basePath: apiBasePath,
97
- });
98
- const queries = createCMSQueryKeys(client, headers);
99
- const limit = 20;
100
-
101
108
  // Prefetch pages using infinite query
102
- const listQuery = queries.cmsContent.list({
103
- typeSlug,
104
- limit,
105
- offset: 0,
106
- });
107
109
  await queryClient.prefetchInfiniteQuery({
108
- queryKey: [...listQuery.queryKey, "ui-builder"],
110
+ queryKey: uiBuilderListQueryKey,
109
111
  queryFn: async ({ pageParam = 0 }) => {
110
112
  const response: unknown = await client("/content/:typeSlug", {
111
113
  method: "GET",
@@ -133,8 +135,7 @@ function createPageListLoader(config: UIBuilderClientConfig) {
133
135
 
134
136
  // Check if there was an error
135
137
  const queryState = queryClient.getQueryState([
136
- ...listQuery.queryKey,
137
- "ui-builder",
138
+ ...uiBuilderListQueryKey,
138
139
  ]);
139
140
  if (queryState?.error && hooks?.onLoadError) {
140
141
  const error =
@@ -145,6 +146,22 @@ function createPageListLoader(config: UIBuilderClientConfig) {
145
146
  }
146
147
  } catch (error) {
147
148
  // Error hook - log the error but don't throw during SSR
149
+ if (isConnectionError(error)) {
150
+ console.warn(
151
+ "[btst/ui-builder] route.loader() failed — no server running at build time. " +
152
+ "Use myStack.api.uiBuilder.prefetchForRoute() for SSG data prefetching.",
153
+ );
154
+ } else {
155
+ const errToStore = createSanitizedSSRLoaderError();
156
+ await queryClient.prefetchInfiniteQuery({
157
+ queryKey: uiBuilderListQueryKey,
158
+ queryFn: () => {
159
+ throw errToStore;
160
+ },
161
+ initialPageParam: 0,
162
+ retry: false,
163
+ });
164
+ }
148
165
  if (hooks?.onLoadError) {
149
166
  await hooks.onLoadError(error as Error, context);
150
167
  }
@@ -173,6 +190,14 @@ function createPageBuilderLoader(
173
190
  apiBasePath,
174
191
  headers,
175
192
  };
193
+ const client = createApiClient<CMSApiRouter>({
194
+ baseURL: apiBaseURL,
195
+ basePath: apiBasePath,
196
+ });
197
+ const queries = createCMSQueryKeys(client, headers);
198
+ const pageQuery = id
199
+ ? queries.cmsContent.detail(typeSlug, id)
200
+ : undefined;
176
201
 
177
202
  try {
178
203
  // Before hook - authorization check
@@ -183,17 +208,9 @@ function createPageBuilderLoader(
183
208
  );
184
209
  }
185
210
 
186
- const client = createApiClient<CMSApiRouter>({
187
- baseURL: apiBaseURL,
188
- basePath: apiBasePath,
189
- });
190
- const queries = createCMSQueryKeys(client, headers);
191
-
192
211
  // Prefetch page if editing
193
212
  if (id) {
194
- await queryClient.prefetchQuery(
195
- queries.cmsContent.detail(typeSlug, id),
196
- );
213
+ await queryClient.prefetchQuery(pageQuery!);
197
214
  }
198
215
 
199
216
  // After hook
@@ -203,9 +220,7 @@ function createPageBuilderLoader(
203
220
 
204
221
  // Check if there was an error
205
222
  if (id) {
206
- const queryState = queryClient.getQueryState(
207
- queries.cmsContent.detail(typeSlug, id).queryKey,
208
- );
223
+ const queryState = queryClient.getQueryState(pageQuery!.queryKey);
209
224
  if (queryState?.error && hooks?.onLoadError) {
210
225
  const error =
211
226
  queryState.error instanceof Error
@@ -216,6 +231,21 @@ function createPageBuilderLoader(
216
231
  }
217
232
  } catch (error) {
218
233
  // Error hook - log the error but don't throw during SSR
234
+ if (isConnectionError(error)) {
235
+ console.warn(
236
+ "[btst/ui-builder] route.loader() failed — no server running at build time. " +
237
+ "Use myStack.api.uiBuilder.prefetchForRoute() for SSG data prefetching.",
238
+ );
239
+ } else if (pageQuery) {
240
+ const errToStore = createSanitizedSSRLoaderError();
241
+ await queryClient.prefetchQuery({
242
+ queryKey: pageQuery.queryKey,
243
+ queryFn: () => {
244
+ throw errToStore;
245
+ },
246
+ retry: false,
247
+ });
248
+ }
219
249
  if (hooks?.onLoadError) {
220
250
  await hooks.onLoadError(error as Error, context);
221
251
  }
@@ -109,6 +109,24 @@ export function isConnectionError(err: unknown): boolean {
109
109
  code === "ERR_CONNECTION_REFUSED"
110
110
  );
111
111
  }
112
+
113
+ /**
114
+ * Public-safe message used when SSR loader failures are intentionally seeded
115
+ * into React Query so Error Boundaries can render on the client.
116
+ *
117
+ * Never include raw server error text here because dehydrated query state can
118
+ * be serialized into HTML.
119
+ */
120
+ export const SSR_LOADER_ERROR_MESSAGE = "Failed to load data.";
121
+
122
+ /**
123
+ * Creates a sanitized Error for SSR loader cache seeding.
124
+ *
125
+ * Use this instead of storing raw server errors in dehydrated query state.
126
+ */
127
+ export function createSanitizedSSRLoaderError(): Error {
128
+ return new Error(SSR_LOADER_ERROR_MESSAGE);
129
+ }
112
130
  import type { Router, Endpoint, Status, statusCodes } from "better-call";
113
131
 
114
132
  interface CreateApiClientOptions {