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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,120 @@ const item = await myStack.api["{name}"].getItemById("abc")
- Re-export getters from `api/index.ts` for consumers who need direct import (SSG/build-time)
- Authorization hooks are **not** called via `stack().api.*` — callers are responsible for access control

### SSG Support (`prefetchForRoute`)

`route.loader()` makes HTTP requests that **silently fail at `next build`** (no server running). Plugins that support SSG expose `prefetchForRoute` on the `api` factory to seed the React Query cache directly from the DB instead.

**Required files per plugin:**

| File | Purpose |
|---|---|
| `api/query-key-defs.ts` | Shared key shapes — import into both `query-keys.ts` and `prefetchForRoute` to prevent drift |
| `api/serializers.ts` | Convert `Date` fields to ISO strings before `setQueryData` |
| `api/getters.ts` | Add any ID-based getters `prefetchForRoute` needs (e.g. `getItemById`) |
| `api/plugin.ts` | `RouteKey` type + typed overloads + wire `prefetchForRoute` into `api` factory |
| `api/index.ts` | Re-export `RouteKey`, serializers, `PLUGIN_QUERY_KEYS` |
| `query-keys.ts` | Import discriminator fn from `api/query-key-defs.ts` |
| `client/plugin.tsx` | Import and call `isConnectionError` in each loader `catch` block |

**`api/query-key-defs.ts`:**
```typescript
export function itemsListDiscriminator(params?: { limit?: number }) {
return { limit: params?.limit ?? 20 }
}
export const PLUGIN_QUERY_KEYS = {
itemsList: (params?: { limit?: number }) =>
["items", "list", itemsListDiscriminator(params)] as const,
itemDetail: (id: string) => ["items", "detail", id] as const,
}
```

**`prefetchForRoute` in `api/plugin.ts`:**
```typescript
export type PluginRouteKey = "list" | "detail" | "new"

// Typed overloads enforce correct params per route key
interface PluginPrefetchForRoute {
(key: "list" | "new", qc: QueryClient): Promise<void>
(key: "detail", qc: QueryClient, params: { id: string }): Promise<void>
}

function createPluginPrefetchForRoute(adapter: Adapter): PluginPrefetchForRoute {
return async function prefetchForRoute(key, qc, params?) {
switch (key) {
case "list": {
const { items, total, limit, offset } = await listItems(adapter)
// useInfiniteQuery lists require { pages, pageParams } shape
qc.setQueryData(PLUGIN_QUERY_KEYS.itemsList(), {
pages: [{ items: items.map(serializeItem), total, limit, offset }],
pageParams: [0],
})
break
}
case "detail": {
const item = await getItemById(adapter, params!.id)
if (item) qc.setQueryData(PLUGIN_QUERY_KEYS.itemDetail(params!.id), serializeItem(item))
break
}
case "new": break
}
} as PluginPrefetchForRoute
}

api: (adapter) => ({
listItems: () => listItems(adapter),
prefetchForRoute: createPluginPrefetchForRoute(adapter),
})
```

Rules: serialize `Date` → ISO string; for plugins with a one-time init step (e.g. CMS `ensureSynced`), call it once at the top of `prefetchForRoute` — it is idempotent and safe for concurrent SSG calls.

**Build-time warning in `client/plugin.tsx` loader `catch` blocks:**
```typescript
import { isConnectionError } from "@btst/stack/plugins/client"

// in each loader catch block:
if (isConnectionError(error)) {
console.warn("[btst/{plugin}] route.loader() failed — no server at build time. Use myStack.api.{plugin}.prefetchForRoute() for SSG.")
}
```

**SSG `page.tsx` pattern (Next.js — outside `[[...all]]/`):**
```tsx
export async function generateStaticParams() { return [{}] }
// export const revalidate = 3600 // ISR

export async function generateMetadata(): Promise<Metadata> {
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(normalizePath(["{plugin}"]))
if (!route) return { title: "Fallback" }
await myStack.api.{plugin}.prefetchForRoute("list", queryClient)
return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata
}

export default async function Page() {
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(normalizePath(["{plugin}"]))
if (!route) notFound()
await myStack.api.{plugin}.prefetchForRoute("list", queryClient)
return <HydrationBoundary state={dehydrate(queryClient)}><route.PageComponent /></HydrationBoundary>
}
```

The shared `StackProvider` layout must be at `app/pages/layout.tsx` (not `[[...all]]/layout.tsx`) so it applies to both SSG pages and the catch-all.

**Plugins with SSG support:**

| Plugin | Prefetched route keys | Skipped |
|---|---|---|
| Blog | `posts`, `drafts`, `post`, `tag`, `editPost` | `newPost` |
| CMS | `dashboard`, `contentList`, `editContent` | `newContent` |
| Form Builder | `formList`, `editForm`, `submissions` | `newForm` |
| Kanban | `boards`, `board` | `newBoard` |
| AI Chat | — (per-user, not static) | all |

### Query Keys Factory

Create a query keys file for React Query integration:
Expand Down Expand Up @@ -568,3 +682,11 @@ The `AutoTypeTable` component automatically pulls from TypeScript files, so ensu
11. **`stack().api` bypasses authorization hooks** - Getters accessed via `myStack.api.*` skip all `onBefore*` hooks. Never use them as a substitute for authenticated HTTP endpoints — enforce access control at the call site.

12. **Plugin init steps not called via `api`** - If a plugin's `routes` factory runs a one-time setup (e.g. CMS `syncContentTypes`), that same setup must also be awaited inside the `api` getter wrappers, otherwise direct getter calls will query an uninitialised database.

13. **`route.loader()` silently fails at build time** - No HTTP server exists during `next build`, so fetches fail silently and the static page renders empty. Use `myStack.api.{plugin}.prefetchForRoute()` in SSG pages instead.

14. **Query key drift between HTTP and SSG paths** - Share key builders via `api/query-key-defs.ts`; import discriminator functions into `query-keys.ts`. Never hardcode key shapes in two places.

15. **Wrong data shape for infinite queries** - Lists backed by `useInfiniteQuery` need `{ pages: [...], pageParams: [...] }` in `setQueryData`. Flat arrays will break hydration.

16. **Dates not serialized before `setQueryData`** - DB getters return `Date` objects; the HTTP cache holds ISO strings. Always serialize (e.g. `serializePost`) before `setQueryData`.
109 changes: 109 additions & 0 deletions docs/content/docs/plugins/blog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -581,3 +581,112 @@ export async function generateStaticParams() {
### `PostListResult`

<AutoTypeTable path="../packages/stack/src/plugins/blog/api/getters.ts" name="PostListResult" />

## Static Site Generation (SSG)

`route.loader()` makes HTTP requests to `apiBaseURL`, which silently fails during `next build` because no dev server is running. Use `prefetchForRoute()` instead — it reads directly from the database and pre-populates the React Query cache before rendering.

### `prefetchForRoute(routeKey, queryClient, params?)`

| Route key | Params required | Data prefetched |
|---|---|---|
| `"posts"` | — | Published posts list |
| `"drafts"` | — | Draft posts list |
| `"post"` | `{ slug: string }` | Single post detail |
| `"tag"` | `{ tagSlug: string }` | Tag + tagged posts |
| `"newPost"` | — | *(nothing)* |
| `"editPost"` | `{ slug: string }` | Post to edit |

### Next.js example

```tsx title="app/pages/blog/page.tsx"
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClient } from "@/lib/stack-client"
import { myStack } from "@/lib/stack"
import { metaElementsToObject, normalizePath } from "@btst/stack/client"
import type { Metadata } from "next"

// Opt into SSG — Next.js generates this page at build time
export async function generateStaticParams() {
return [{}]
}

// export const revalidate = 3600 // uncomment for ISR (1 hour)

export async function generateMetadata(): Promise<Metadata> {
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(normalizePath(["blog"]))
if (!route) return { title: "Blog" }
await myStack.api.blog.prefetchForRoute("posts", queryClient)
return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata
}

export default async function BlogListPage() {
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(normalizePath(["blog"]))
if (!route) return null
// Reads directly from DB — works at build time, no HTTP server required
await myStack.api.blog.prefetchForRoute("posts", queryClient)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<route.PageComponent />
</HydrationBoundary>
)
}
```

For individual post pages, also generate the static params list:

```tsx title="app/pages/blog/[slug]/page.tsx"
export async function generateStaticParams() {
const { items } = await myStack.api.blog.getAllPosts({ published: true, limit: 1000 })
return items.map((p) => ({ slug: p.slug }))
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(normalizePath(["blog", params.slug]))
if (!route) return null
await myStack.api.blog.prefetchForRoute("post", queryClient, { slug: params.slug })
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<route.PageComponent />
</HydrationBoundary>
)
}
```

### ISR cache invalidation

If you use [Incremental Static Regeneration](https://nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration), the static page cache must be purged whenever content changes. Wire up `revalidatePath` (or `revalidateTag`) inside the backend lifecycle hooks so Next.js regenerates the page on the next request:

```ts title="lib/stack.ts"
import { revalidatePath } from "next/cache"
import type { BlogBackendHooks } from "@btst/stack/plugins/blog"

const blogHooks: BlogBackendHooks = {
onPostCreated: async (post) => {
revalidatePath("/blog")
revalidatePath(`/blog/${post.slug}`)
},
onPostUpdated: async (post) => {
revalidatePath("/blog")
revalidatePath(`/blog/${post.slug}`)
},
onPostDeleted: async (postId) => {
revalidatePath("/blog")
},
}
```

<Callout type="info">
`revalidatePath` / `revalidateTag` are Next.js APIs — import them from `"next/cache"`. They are no-ops outside of a Next.js runtime, so this pattern is safe to use in the `lib/stack.ts` shared file without breaking other frameworks.
</Callout>

### Query key consistency

`prefetchForRoute` uses the same query key shapes as `createBlogQueryKeys` (the HTTP client). The shared constants live in `@btst/stack/plugins/blog/api` as `BLOG_QUERY_KEYS` and `postsListDiscriminator`, so the two paths can never drift silently.
86 changes: 86 additions & 0 deletions docs/content/docs/plugins/cms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1287,3 +1287,89 @@ export async function generateStaticParams() {
| `getAllContentTypes(adapter)` | Returns all registered content types, sorted by name |
| `getAllContentItems(adapter, typeSlug, params?)` | Returns paginated items for a content type |
| `getContentItemBySlug(adapter, typeSlug, slug)` | Returns a single item by slug, or `null` |

## Static Site Generation (SSG)

`route.loader()` makes HTTP requests to `apiBaseURL`, which silently fails during `next build` because no dev server is running. Use `prefetchForRoute()` instead — it reads directly from the database and pre-populates the React Query cache before rendering.

### `prefetchForRoute(routeKey, queryClient, params?)`

| Route key | Params required | Data prefetched |
|---|---|---|
| `"dashboard"` | — | All content types (with item counts) |
| `"contentList"` | `{ typeSlug: string }` | Content types + first page of items |
| `"newContent"` | — | All content types |
| `"editContent"` | `{ typeSlug: string; id: string }` | Content types + specific item |

<Callout type="info">
`prefetchForRoute` calls `ensureSynced(adapter)` internally before any DB query. This function is idempotent — concurrent calls during `generateStaticParams` + `generateMetadata` + `page` all share the same Promise and the schema sync runs exactly once.
</Callout>

### Next.js example

```tsx title="app/pages/cms/[typeSlug]/page.tsx"
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClient } from "@/lib/stack-client"
import { myStack } from "@/lib/stack"
import { metaElementsToObject, normalizePath } from "@btst/stack/client"
import type { Metadata } from "next"

// Generate one static page per content type slug
export async function generateStaticParams() {
const types = await myStack.api.cms.getAllContentTypes()
return types.map((t) => ({ typeSlug: t.slug }))
}

export async function generateMetadata(
{ params }: { params: { typeSlug: string } }
): Promise<Metadata> {
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(normalizePath(["cms", params.typeSlug]))
if (!route) return { title: "Content" }
await myStack.api.cms.prefetchForRoute("contentList", queryClient, { typeSlug: params.typeSlug })
return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata
}

export default async function ContentListPage({ params }: { params: { typeSlug: string } }) {
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(normalizePath(["cms", params.typeSlug]))
if (!route) return null
await myStack.api.cms.prefetchForRoute("contentList", queryClient, { typeSlug: params.typeSlug })
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<route.PageComponent />
</HydrationBoundary>
)
}
```

### ISR cache invalidation

If you use [Incremental Static Regeneration](https://nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration), call `revalidatePath` inside the backend lifecycle hooks so Next.js regenerates the page on the next request:

```ts title="lib/stack.ts"
import { revalidatePath } from "next/cache"
import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api"

cmsBackendPlugin({
contentTypes: { ... },
hooks: {
onAfterCreate: async (item, context) => {
revalidatePath(`/cms/${context.typeSlug}`, "page")
},
onAfterUpdate: async (item, context) => {
revalidatePath(`/cms/${context.typeSlug}`, "page")
},
onAfterDelete: async (id, context) => {
revalidatePath(`/cms/${context.typeSlug}`, "page")
},
},
})
```

### Query key consistency

`prefetchForRoute` uses the same query key shapes as `createCMSQueryKeys` (the HTTP client). The shared constants live in `@btst/stack/plugins/cms/api` as `CMS_QUERY_KEYS` and `contentListDiscriminator`, so the two paths can never drift silently.
Loading