File structure
src/plugins/{name}/
client/
constants.ts ← one literal programmatic plugin id
plugin.tsx ← defineClientPlugin entry
hooks.ts ← "use client" React hooks only
components/
pages/
my-page.tsx ← wrapper: ComposedRoute + lazy import
my-page.internal.tsx ← actual UI: useSuspenseQuery
query-keys.ts ← resource declaration + query key factory
Data hooks: use createResource (new plugins)
Don't hand-write the useQuery/useMutation + isErrorResponse/toError plumbing.
Declare the plugin's resources once in query-keys.ts and generate everything:
// query-keys.ts (server-safe — no React)
import { createResourceQueryKeys, type ResourcesDeclaration } from "@btst/stack/plugins/client";
export const myResources = {
posts: {
queries: {
list: { path: "/posts", query: (p?: ListParams) => ({...}), key: (p?: ListParams) => [discriminator(p)],
select: (d: any): Item[] => d?.items ?? [], infinite: true, pageSize: (p?: ListParams) => p?.limit ?? 10 },
detail: { path: "/posts", query: (slug: string) => ({ slug, limit: 1 }), key: (slug: string) => [slug],
select: (d: any): Item | null => d?.items?.[0] ?? null, skip: (slug: string) => !slug },
},
mutations: {
create: { path: "@post/posts", method: "POST", input: (vars: CreateInput) => ({ body: vars }),
select: (d: any) => d as Item | null, invalidates: ["posts.list"],
setData: { query: "detail", args: (r: Item | null) => (r?.slug ? [r.slug] : null) } },
},
},
} satisfies ResourcesDeclaration;
// SSR loaders keep using the same factory (keys match @lukemorales shapes)
export function createMyQueryKeys(client, headers?: HeadersInit) {
return createResourceQueryKeys(client, myResources, headers);
}
// client/hooks.ts ("use client")
import { createResource } from "@btst/stack/plugins/client/hooks";
import { myResources } from "../query-keys";
import { MY_PLUGIN_ID } from "./constants";
const my = createResource({ plugin: MY_PLUGIN_ID, resources: myResources });
export const usePosts = (params?: ListParams) => my.posts.list.useInfinite([params]);
export const useSuspensePost = (slug: string) => my.posts.detail.useSuspense([slug]);
export const useCreatePost = () => my.posts.create.use();
Generated per query: use(args, { enabled? }), useSuspense(args) (plain) or
useInfinite/useSuspenseInfinite (when infinite: true). Suspense variants re-throw
refetch errors automatically. Mutations get use() with declarative invalidates
("resource" or "resource.query" prefixes), optional setData cache seeding, and an
awaited refresh() after invalidation. Errors are normalized to StackError
(statusCode, field-level errors from Zod issues).
Each resource also exposes:
my.posts.useForm({ action, id?, record?, defaults?, toCreateVars?, toUpdateVars?, successMessage?, errorMessage?, redirect?, onSuccess? })— create/edit lifecycle: fetches the record for edit, runs the right mutation, notifies viauseNotify(), redirects via the router adapter, and exposesfieldErrors(map server Zod issues onto react-hook-form withsetError).my.posts.useSelect({ searchArgs, getOptionValue, getOptionLabel, value?, preload? })— debounced server-side search + current-value preloading for relation pickers.
SHARED_QUERY_CONFIG, isErrorResponse, toError, and StackError live in
@btst/stack/plugins/client — never copy them into a plugin. The blog plugin
(src/plugins/blog/query-keys.ts, client/hooks/blog-hooks.tsx) is the reference
consumer.
Server/client module boundary
client/plugin.tsx must stay import-safe on the server. Next.js (including SSG build)
can execute createClientStack() on the server, which resolves each *ClientPlugin()
factory. If that module is marked "use client" or imports a client-only module, build
can fail with "Attempted to call ... from the server".
Rules:
- Do not add
"use client"toclient/plugin.tsx. - Keep
client/plugin.tsxfree of React hooks (useState,useEffect, etc.). - Put hook utilities in a separate client-only module (
client/hooks.ts) with"use client", and re-export them fromclient/index.ts. createResourcecomes from the client-only entry@btst/stack/plugins/client/hooks; the server-safe@btst/stack/plugins/cliententry only exposescreateResourceQueryKeys+ declaration types forquery-keys.tsand loaders.- UI components can remain client components as needed; only the plugin factory entry must stay server-import-safe.
Route anatomy
Use defineRoute / defineRoutes for new routes. The page, loader, and
meta handlers on a parameterized route each receive the route context:
routes: () => defineRoutes({
myRoute: defineRoute("/path/:id", {
page: ({ params }) => <MyPageComponent id={params.id} />,
loader: ({ params }) => createMyLoader(params.id, config)(), // SSR only
meta: ({ params }) => createMyMeta(params.id, config)(), // SEO tags
}),
})
Lazy loading
Use React.lazy() for all page components. Named exports need .then():
const MyPage = lazy(() =>
import("./components/pages/my-page").then(m => ({ default: m.MyPage }))
)
// Default exports: lazy(() => import("./components/pages/my-page"))
SSR loader rules
- Only execute inside
if (typeof window === "undefined")guard - Never throw — store errors in React Query, let ErrorBoundary catch during render
- Call
beforeLoad/afterLoad/onErrorLoadhooks - Use
queryClient.prefetchQuery()to seed data - Import
isConnectionErrorfrom@btst/stack/plugins/clientand warn on build-time failure
ComposedRoute (wrapper page)
export function MyPageComponent({ id }: { id: string }) {
return (
<ComposedRoute
path={`/plugin/${id}`}
PageComponent={MyPage} // lazy loaded
ErrorComponent={DefaultError}
LoadingComponent={PageSkeleton} // always provide — never guard with typeof window
NotFoundComponent={NotFoundPage}
props={{ id }}
onError={(error) => console.error(error)}
/>
)
}
Critical: always pass LoadingComponent unconditionally — guarding it with typeof window !== "undefined" shifts React's useId() counter and causes hydration mismatches.
useSuspenseQuery + error throwing (.internal.tsx)
useSuspenseQuery only throws on initial fetch. Manually re-throw on refetch errors:
export function useMyData(id: string) {
const { data, refetch, error, isFetching } = useSuspenseQuery({
...queries.items.detail(id),
staleTime: 60_000,
retry: false,
})
if (error && !isFetching) throw error // ← required for ErrorBoundary to catch refetch failures
return { data, refetch }
}
Provider wiring and client overrides
Shared API, site, and QueryClient values belong on the resolved client stack, not inside plugin options or provider overrides:
<StackProvider
stack={clientStack}
router={nextRouter()}
auth={createClientAuth({ authorization, getIdentity, loginPath: "/login" })}
overrides={{ myPlugin: { uploadImage, localization } }}
>
{children}
</StackProvider>
Plugin override types contain only plugin-specific customization:
type PluginOverrides = {
uploadImage?: (file: File) => Promise<string>
localization?: Partial<Localization>
}
The plugin factory receives only plugin-specific choices such as SEO and
loader hooks. Its resolve(runtime) callback receives API, site, QueryClient,
headers, and credentials from createClientStack(). Keep factory options
independent from StackProvider overrides; never add a config(overrides)
adapter that copies provider fields back into the plugin.
Gotchas
- Framework config in plugin overrides —
Link,Image, navigation, and refresh come fromStackProvider; API paths come from the resolved client stack. - Building plugin config from overrides — plugin factory config is created
in
getStackClient(queryClient); provider overrides are browser-runtime customization only. staleTime: Infinity— use for data that should not auto-refetch.- Next.js Link href undefined — use
href={href || "#"}pattern. - Suspense errors not caught — add
if (error && !isFetching) throw errorin every suspense hook. - Missing ComposedRoute wrapper — without it, errors crash the entire app instead of hitting ErrorBoundary.
- Client directive on
client/plugin.tsx— can break SSG/SSR when plugin factories are invoked server-side.
Full code patterns
See REFERENCE.md for complete SSR loader, meta generator, and query key factory code. See EXAMPLES.md for a full ComposedRoute + internal page wiring example.