diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx
index 0a531725..99e0037a 100644
--- a/docs/content/docs/plugins/blog.mdx
+++ b/docs/content/docs/plugins/blog.mdx
@@ -585,18 +585,18 @@ You can import the hooks from `"@btst/stack/plugins/blog/client/hooks"` to use i
## Server-side Data Access
-The blog plugin exposes standalone getter functions for server-side and SSG use cases. These bypass the HTTP layer entirely and query the database directly.
+The blog plugin exposes standalone getter and mutation functions for server-side use cases. These bypass the HTTP layer entirely and query the database directly — no authorization hooks are called, so the caller is responsible for any access-control checks.
### Two patterns
**Pattern 1 — via `stack().api` (recommended for runtime server code)**
-After calling `stack()`, the returned object includes a fully-typed `api` namespace. Getters are pre-bound to the adapter:
+After calling `stack()`, the returned object includes a fully-typed `api` namespace. Getters and mutations are pre-bound to the adapter:
```ts title="app/lib/stack.ts"
import { myStack } from "./stack"; // your stack() instance
-// In a Server Component, generateStaticParams, etc.
+// Getters — read-only
const result = await myStack.api.blog.getAllPosts({ published: true });
// result.items — Post[]
// result.total — total count before pagination
@@ -605,22 +605,45 @@ const result = await myStack.api.blog.getAllPosts({ published: true });
const post = await myStack.api.blog.getPostBySlug("hello-world");
const tags = await myStack.api.blog.getAllTags();
+
+// Mutations — write operations (no auth hooks are called)
+const newPost = await myStack.api.blog.createPost({
+ title: "Hello World",
+ slug: "hello-world",
+ content: "...",
+ excerpt: "...",
+});
+await myStack.api.blog.updatePost(newPost.id, { published: true });
+await myStack.api.blog.deletePost(newPost.id);
```
**Pattern 2 — direct import (SSG, build-time, or custom adapter)**
-Import getters directly and pass any `Adapter`:
+Import getters and mutations directly and pass any `Adapter`:
```ts
-import { getAllPosts, getPostBySlug, getAllTags } from "@btst/stack/plugins/blog/api";
+import { getAllPosts, createPost, updatePost, deletePost } from "@btst/stack/plugins/blog/api";
// e.g. in Next.js generateStaticParams
export async function generateStaticParams() {
const { items } = await getAllPosts(myAdapter, { published: true });
return items.map((p) => ({ slug: p.slug }));
}
+
+// e.g. seeding or scripting
+const post = await createPost(myAdapter, {
+ title: "Seeded Post",
+ slug: "seeded-post",
+ content: "Content here",
+ excerpt: "Short excerpt",
+});
+await updatePost(myAdapter, post.id, { published: true });
```
+
+**No authorization hooks are called** when using `stack().api.*` or direct imports. These functions hit the database directly. Always perform your own access-control checks before calling them from user-facing code.
+
+
### Available getters
| Function | Returns | Description |
@@ -637,6 +660,22 @@ export async function generateStaticParams() {
+### Available mutations
+
+| Function | Returns | Description |
+|---|---|---|
+| `createPost(adapter, input)` | `Post` | Create a new post with optional tag associations |
+| `updatePost(adapter, id, input)` | `Post \| null` | Update a post and reconcile its tags; `null` if not found |
+| `deletePost(adapter, id)` | `void` | Delete a post by ID |
+
+### `CreatePostInput`
+
+
+
+### `UpdatePostInput`
+
+
+
## 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.
diff --git a/packages/stack/package.json b/packages/stack/package.json
index 05e69551..2311d16b 100644
--- a/packages/stack/package.json
+++ b/packages/stack/package.json
@@ -1,6 +1,6 @@
{
"name": "@btst/stack",
- "version": "2.11.1",
+ "version": "2.11.2",
"description": "A composable, plugin-based library for building full-stack applications.",
"repository": {
"type": "git",
diff --git a/packages/stack/src/plugins/blog/api/index.ts b/packages/stack/src/plugins/blog/api/index.ts
index f4c96743..1416f7a0 100644
--- a/packages/stack/src/plugins/blog/api/index.ts
+++ b/packages/stack/src/plugins/blog/api/index.ts
@@ -6,6 +6,13 @@ export {
type PostListParams,
type PostListResult,
} from "./getters";
+export {
+ createPost,
+ updatePost,
+ deletePost,
+ type CreatePostInput,
+ type UpdatePostInput,
+} from "./mutations";
export { serializePost, serializeTag } from "./serializers";
export { BLOG_QUERY_KEYS } from "./query-key-defs";
export { createBlogQueryKeys } from "../query-keys";
diff --git a/packages/stack/src/plugins/blog/api/mutations.ts b/packages/stack/src/plugins/blog/api/mutations.ts
new file mode 100644
index 00000000..78476ab5
--- /dev/null
+++ b/packages/stack/src/plugins/blog/api/mutations.ts
@@ -0,0 +1,287 @@
+import type { DBAdapter as Adapter } from "@btst/db";
+import type { Post, Tag } from "../types";
+import { slugify } from "../utils";
+
+type TagInput = { name: string } | { id: string; name: string; slug: string };
+
+/**
+ * Find existing tags by slug or create missing ones, then return the resolved Tag records.
+ * Tags that already carry an `id` are returned as-is (after name normalisation).
+ */
+async function findOrCreateTags(
+ adapter: Adapter,
+ tagInputs: TagInput[],
+): Promise {
+ if (tagInputs.length === 0) return [];
+
+ const normalizeTagName = (name: string): string => name.trim();
+
+ const tagsWithIds: Tag[] = [];
+ const tagsToFindOrCreate: Array<{ name: string }> = [];
+
+ for (const tagInput of tagInputs) {
+ if ("id" in tagInput && tagInput.id) {
+ tagsWithIds.push({
+ id: tagInput.id,
+ name: normalizeTagName(tagInput.name),
+ slug: tagInput.slug,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ } as Tag);
+ } else {
+ tagsToFindOrCreate.push({ name: normalizeTagName(tagInput.name) });
+ }
+ }
+
+ if (tagsToFindOrCreate.length === 0) {
+ return tagsWithIds;
+ }
+
+ const allTags = await adapter.findMany({ model: "tag" });
+ const tagMapBySlug = new Map();
+ for (const tag of allTags) {
+ tagMapBySlug.set(tag.slug, tag);
+ }
+
+ const tagSlugs = tagsToFindOrCreate.map((tag) => slugify(tag.name));
+ const foundTags: Tag[] = [];
+ for (const slug of tagSlugs) {
+ const tag = tagMapBySlug.get(slug);
+ if (tag) {
+ foundTags.push(tag);
+ }
+ }
+
+ const existingSlugs = new Set([
+ ...tagsWithIds.map((tag) => tag.slug),
+ ...foundTags.map((tag) => tag.slug),
+ ]);
+ const tagsToCreate = tagsToFindOrCreate.filter(
+ (tag) => !existingSlugs.has(slugify(tag.name)),
+ );
+
+ const createdTags: Tag[] = [];
+ for (const tag of tagsToCreate) {
+ const normalizedName = normalizeTagName(tag.name);
+ const newTag = await adapter.create({
+ model: "tag",
+ data: {
+ name: normalizedName,
+ slug: slugify(normalizedName),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ });
+ createdTags.push(newTag);
+ }
+
+ return [...tagsWithIds, ...foundTags, ...createdTags];
+}
+
+/**
+ * Input for creating a new blog post.
+ * `slug` must already be slugified by the caller.
+ */
+export interface CreatePostInput {
+ title: string;
+ content: string;
+ excerpt: string;
+ /** Pre-slugified URL slug — use {@link slugify} before passing. */
+ slug: string;
+ image?: string;
+ published?: boolean;
+ publishedAt?: Date;
+ createdAt?: Date;
+ updatedAt?: Date;
+ tags?: TagInput[];
+}
+
+/**
+ * Input for updating an existing blog post.
+ * If `slug` is provided it must already be slugified by the caller.
+ */
+export interface UpdatePostInput {
+ title?: string;
+ content?: string;
+ excerpt?: string;
+ /** Pre-slugified URL slug — use {@link slugify} before passing. */
+ slug?: string;
+ image?: string;
+ published?: boolean;
+ publishedAt?: Date;
+ createdAt?: Date;
+ updatedAt?: Date;
+ tags?: TagInput[];
+}
+
+/**
+ * Create a new blog post with optional tag associations.
+ * Pure DB function — no hooks, no HTTP context. Safe for server-side and SSG use.
+ *
+ * @remarks **Security:** Authorization hooks (e.g. `onBeforeCreatePost`) are NOT
+ * called. The caller is responsible for any access-control checks before
+ * invoking this function.
+ *
+ * @param adapter - The database adapter
+ * @param input - Post data; `slug` must be pre-slugified
+ */
+export async function createPost(
+ adapter: Adapter,
+ input: CreatePostInput,
+): Promise {
+ const { tags: tagInputs, ...postData } = input;
+ const tagList = tagInputs ?? [];
+
+ const newPost = await adapter.create({
+ model: "post",
+ data: {
+ ...postData,
+ published: postData.published ?? false,
+ tags: [] as Tag[],
+ createdAt: postData.createdAt ?? new Date(),
+ updatedAt: postData.updatedAt ?? new Date(),
+ },
+ });
+
+ if (tagList.length > 0) {
+ const resolvedTags = await findOrCreateTags(adapter, tagList);
+
+ await adapter.transaction(async (tx) => {
+ for (const tag of resolvedTags) {
+ await tx.create<{ postId: string; tagId: string }>({
+ model: "postTag",
+ data: {
+ postId: newPost.id,
+ tagId: tag.id,
+ },
+ });
+ }
+ });
+
+ newPost.tags = resolvedTags.map((tag) => ({ ...tag }));
+ } else {
+ newPost.tags = [];
+ }
+
+ return newPost;
+}
+
+/**
+ * Update an existing blog post and reconcile its tag associations.
+ * Returns `null` if no post with the given `id` exists.
+ * Pure DB function — no hooks, no HTTP context. Safe for server-side use.
+ *
+ * @remarks **Security:** Authorization hooks (e.g. `onBeforeUpdatePost`) are NOT
+ * called. The caller is responsible for any access-control checks before
+ * invoking this function.
+ *
+ * @param adapter - The database adapter
+ * @param id - The post ID to update
+ * @param input - Partial post data to apply; `slug` must be pre-slugified if provided
+ */
+export async function updatePost(
+ adapter: Adapter,
+ id: string,
+ input: UpdatePostInput,
+): Promise {
+ const { tags: tagInputs, ...postData } = input;
+
+ return adapter.transaction(async (tx) => {
+ const updatedPost = await tx.update({
+ model: "post",
+ where: [{ field: "id", value: id }],
+ update: {
+ ...postData,
+ updatedAt: new Date(),
+ },
+ });
+
+ if (!updatedPost) return null;
+
+ if (tagInputs !== undefined) {
+ const existingPostTags = await tx.findMany<{
+ postId: string;
+ tagId: string;
+ }>({
+ model: "postTag",
+ where: [{ field: "postId", value: id, operator: "eq" as const }],
+ });
+
+ for (const postTag of existingPostTags) {
+ await tx.delete<{ postId: string; tagId: string }>({
+ model: "postTag",
+ where: [
+ {
+ field: "postId",
+ value: postTag.postId,
+ operator: "eq" as const,
+ },
+ {
+ field: "tagId",
+ value: postTag.tagId,
+ operator: "eq" as const,
+ },
+ ],
+ });
+ }
+
+ if (tagInputs.length > 0) {
+ const resolvedTags = await findOrCreateTags(adapter, tagInputs);
+
+ for (const tag of resolvedTags) {
+ await tx.create<{ postId: string; tagId: string }>({
+ model: "postTag",
+ data: {
+ postId: id,
+ tagId: tag.id,
+ },
+ });
+ }
+
+ updatedPost.tags = resolvedTags.map((tag) => ({ ...tag }));
+ } else {
+ updatedPost.tags = [];
+ }
+ } else {
+ const existingPostTags = await tx.findMany<{
+ postId: string;
+ tagId: string;
+ }>({
+ model: "postTag",
+ where: [{ field: "postId", value: id, operator: "eq" as const }],
+ });
+
+ if (existingPostTags.length > 0) {
+ const tagIds = existingPostTags.map((pt) => pt.tagId);
+ const tags = await tx.findMany({
+ model: "tag",
+ });
+ updatedPost.tags = tags
+ .filter((tag) => tagIds.includes(tag.id))
+ .map((tag) => ({ ...tag }));
+ } else {
+ updatedPost.tags = [];
+ }
+ }
+
+ return updatedPost;
+ });
+}
+
+/**
+ * Delete a blog post by ID.
+ * Pure DB function — no hooks, no HTTP context. Safe for server-side use.
+ *
+ * @remarks **Security:** Authorization hooks (e.g. `onBeforeDeletePost`) are NOT
+ * called. The caller is responsible for any access-control checks before
+ * invoking this function.
+ *
+ * @param adapter - The database adapter
+ * @param id - The post ID to delete
+ */
+export async function deletePost(adapter: Adapter, id: string): Promise {
+ await adapter.delete({
+ model: "post",
+ where: [{ field: "id", value: id }],
+ });
+}
diff --git a/packages/stack/src/plugins/blog/api/plugin.ts b/packages/stack/src/plugins/blog/api/plugin.ts
index 31e08c5c..274528da 100644
--- a/packages/stack/src/plugins/blog/api/plugin.ts
+++ b/packages/stack/src/plugins/blog/api/plugin.ts
@@ -7,6 +7,13 @@ import type { Post, PostWithPostTag, Tag } from "../types";
import { slugify } from "../utils";
import { createPostSchema, updatePostSchema } from "../schemas";
import { getAllPosts, getPostBySlug, getAllTags } from "./getters";
+import {
+ createPost as createPostMutation,
+ updatePost as updatePostMutation,
+ deletePost as deletePostMutation,
+ type CreatePostInput,
+ type UpdatePostInput,
+} from "./mutations";
import { BLOG_QUERY_KEYS } from "./query-key-defs";
import { serializePost, serializeTag } from "./serializers";
import type { QueryClient } from "@tanstack/react-query";
@@ -260,85 +267,15 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
getPostBySlug: (slug: string) => getPostBySlug(adapter, slug),
getAllTags: () => getAllTags(adapter),
prefetchForRoute: createBlogPrefetchForRoute(adapter),
+ // Mutations
+ createPost: (input: CreatePostInput) =>
+ createPostMutation(adapter, input),
+ updatePost: (id: string, input: UpdatePostInput) =>
+ updatePostMutation(adapter, id, input),
+ deletePost: (id: string) => deletePostMutation(adapter, id),
}),
routes: (adapter: Adapter) => {
- const findOrCreateTags = async (
- tagInputs: Array<
- { name: string } | { id: string; name: string; slug: string }
- >,
- ): Promise => {
- if (tagInputs.length === 0) return [];
-
- const normalizeTagName = (name: string): string => {
- return name.trim();
- };
-
- const tagsWithIds: Tag[] = [];
- const tagsToFindOrCreate: Array<{ name: string }> = [];
-
- for (const tagInput of tagInputs) {
- if ("id" in tagInput && tagInput.id) {
- tagsWithIds.push({
- id: tagInput.id,
- name: normalizeTagName(tagInput.name),
- slug: tagInput.slug,
- createdAt: new Date(),
- updatedAt: new Date(),
- } as Tag);
- } else {
- tagsToFindOrCreate.push({ name: normalizeTagName(tagInput.name) });
- }
- }
-
- if (tagsToFindOrCreate.length === 0) {
- return tagsWithIds;
- }
-
- const allTags = await adapter.findMany({
- model: "tag",
- });
- const tagMapBySlug = new Map();
- for (const tag of allTags) {
- tagMapBySlug.set(tag.slug, tag);
- }
-
- const tagSlugs = tagsToFindOrCreate.map((tag) => slugify(tag.name));
- const foundTags: Tag[] = [];
-
- for (const slug of tagSlugs) {
- const tag = tagMapBySlug.get(slug);
- if (tag) {
- foundTags.push(tag);
- }
- }
-
- const existingSlugs = new Set([
- ...tagsWithIds.map((tag) => tag.slug),
- ...foundTags.map((tag) => tag.slug),
- ]);
- const tagsToCreate = tagsToFindOrCreate.filter(
- (tag) => !existingSlugs.has(slugify(tag.name)),
- );
-
- const createdTags: Tag[] = [];
- for (const tag of tagsToCreate) {
- const normalizedName = normalizeTagName(tag.name);
- const newTag = await adapter.create({
- model: "tag",
- data: {
- name: normalizedName,
- slug: slugify(normalizedName),
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- });
- createdTags.push(newTag);
- }
-
- return [...tagsWithIds, ...foundTags, ...createdTags];
- };
-
const listPosts = createEndpoint(
"/posts",
{
@@ -394,11 +331,17 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
);
}
- const { tags, ...postData } = ctx.body;
- const tagNames = tags || [];
+ // Destructure and discard createdAt/updatedAt — timestamps are always server-generated
+ const {
+ tags,
+ slug: rawSlug,
+ createdAt: _ca,
+ updatedAt: _ua,
+ ...postData
+ } = ctx.body;
// Always slugify to ensure URL-safe slug, whether provided or generated from title
- const slug = slugify(postData.slug || postData.title);
+ const slug = slugify(rawSlug || postData.title);
// Validate that slugification produced a non-empty result
if (!slug) {
@@ -408,37 +351,14 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
});
}
- const newPost = await adapter.create({
- model: "post",
- data: {
- ...postData,
- slug,
- tags: [],
- createdAt: new Date(),
- updatedAt: new Date(),
- },
+ const newPost = await createPostMutation(adapter, {
+ ...postData,
+ slug,
+ tags: tags ?? [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
});
- if (tagNames.length > 0) {
- const createdTags = await findOrCreateTags(tagNames);
-
- await adapter.transaction(async (tx) => {
- for (const tag of createdTags) {
- await tx.create<{ postId: string; tagId: string }>({
- model: "postTag",
- data: {
- postId: newPost.id,
- tagId: tag.id,
- },
- });
- }
- });
-
- newPost.tags = createdTags.map((tag) => ({ ...tag }));
- } else {
- newPost.tags = [];
- }
-
if (hooks?.onPostCreated) {
await hooks.onPostCreated(newPost, context);
}
@@ -475,8 +395,14 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
);
}
- const { tags, slug: rawSlug, ...restPostData } = ctx.body;
- const tagNames = tags || [];
+ // Destructure and discard createdAt/updatedAt — timestamps are always server-generated
+ const {
+ tags,
+ slug: rawSlug,
+ createdAt: _ca,
+ updatedAt: _ua,
+ ...restPostData
+ } = ctx.body;
// Sanitize slug if provided to ensure it's URL-safe
const slugified = rawSlug ? slugify(rawSlug) : undefined;
@@ -489,80 +415,16 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
});
}
- const postData = {
+ const updated = await updatePostMutation(adapter, ctx.params.id, {
...restPostData,
...(slugified ? { slug: slugified } : {}),
- };
-
- const updated = await adapter.transaction(async (tx) => {
- const existingPostTags = await tx.findMany<{
- postId: string;
- tagId: string;
- }>({
- model: "postTag",
- where: [
- {
- field: "postId",
- value: ctx.params.id,
- operator: "eq" as const,
- },
- ],
- });
-
- const updatedPost = await tx.update({
- model: "post",
- where: [{ field: "id", value: ctx.params.id }],
- update: {
- ...postData,
- updatedAt: new Date(),
- },
- });
-
- if (!updatedPost) {
- throw ctx.error(404, {
- message: "Post not found",
- });
- }
-
- for (const postTag of existingPostTags) {
- await tx.delete<{ postId: string; tagId: string }>({
- model: "postTag",
- where: [
- {
- field: "postId",
- value: postTag.postId,
- operator: "eq" as const,
- },
- {
- field: "tagId",
- value: postTag.tagId,
- operator: "eq" as const,
- },
- ],
- });
- }
-
- if (tagNames.length > 0) {
- const createdTags = await findOrCreateTags(tagNames);
-
- for (const tag of createdTags) {
- await tx.create<{ postId: string; tagId: string }>({
- model: "postTag",
- data: {
- postId: ctx.params.id,
- tagId: tag.id,
- },
- });
- }
-
- updatedPost.tags = createdTags.map((tag) => ({ ...tag }));
- } else {
- updatedPost.tags = [];
- }
-
- return updatedPost;
+ tags: tags ?? [],
});
+ if (!updated) {
+ throw ctx.error(404, { message: "Post not found" });
+ }
+
if (hooks?.onPostUpdated) {
await hooks.onPostUpdated(updated, context);
}
@@ -597,10 +459,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
);
}
- await adapter.delete({
- model: "post",
- where: [{ field: "id", value: ctx.params.id }],
- });
+ await deletePostMutation(adapter, ctx.params.id);
// Lifecycle hook
if (hooks?.onPostDeleted) {