Blog publishing is handled inside the app. Admins and moderators can create posts, upload cover images, edit existing posts, delete posts, and publish content without changing source files.
Publishing Access
Create, update, delete, and cover upload routes require a signed in user with the admin or moderator role.
const user = await getSessionUser()
if (!user || (!isAdmin(user) && !isModerator(user))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}Normal users can read published blog pages. Publishing actions are protected server side through the API routes.
Admin Pages
The editor pages live in the blog route group.
/blog/create
/blog/edit?postId=...Admins and moderators can also start from the public blog page. When a signed in user has the admin or moderator role, the blog page shows a fixed Create post button in the bottom left corner. Clicking it sends them to the blog creation page. Normal users do not see this button.
The route files stay thin. The UI lives in feature components:
src/features/blog/components/create-blog-post-page.tsxsrc/features/blog/components/blog-create-post-card.tsxsrc/features/blog/components/edit-blog-post-page.tsxsrc/features/blog/components/blog-post-form.tsxsrc/features/blog/components/blog-content-editor.tsx
Post Fields
The create schema lives in src/features/blog/schemas/create-blog-post.schema.ts.
export const createBlogPostSchema = z.object({
title: z.string().trim().min(1),
slug: z.string().trim().min(1),
preview: z.string().trim().min(1),
seoKeywords: z.array(z.string().trim().min(1)),
imageSrc: z.string().trim().min(1),
content: z.record(z.string(), z.unknown()),
})The important fields are:
titlefor the post heading and metadata title.slugfor the public URL.previewfor cards, RSS descriptions, and meta descriptions.seoKeywordsfor metadata keywords and RSS categories.imageSrcfor the cover image and social preview image.contentfor sanitized rich content stored as JSON.
Cover Uploads
Cover image uploads go through src/app/api/blog/upload-cover/route.ts.
const uploaded = await put(`blog-covers/${Date.now()}-${sanitizeFilename(file.name)}`, file, {
access: "public",
addRandomSuffix: true,
token: ServerEnv.BLOB_PUBLIC_READ_WRITE_TOKEN,
})The route only accepts image files up to 10MB. Blog cover images use the public Blob token because they are shown on public blog pages, Open Graph previews, Twitter cards, and RSS readers.
Content Sanitizing
Blog content is sanitized before it is written to the database.
const sanitizedContent = sanitizeBlogPostContent(parsed.data.content)The sanitizer strips unsafe tags such as script and style, keeps normal writing tags such as paragraphs, headings, lists, links, tables, images, inline code, and code blocks, and restricts link protocols to safe values.
The sanitizer lives in src/features/blog/utils/blog-content-sanitize.server.ts.
Create Flow
The create flow is:
- The admin or moderator opens
/blog/create. - The editor collects title, slug, preview, keywords, cover image, and content.
- Cover images are uploaded to Vercel Blob.
- The form posts to
POST /api/blog. - The server validates the body with Zod.
- The server sanitizes the content.
- The post is written to the database.
- The blog cache tag is revalidated.
- The user is sent to the published post.
revalidateTag(BLOG_POSTS_CACHE_TAG, "max")Update And Delete
Updates go through PATCH /api/blog/:postId. Deletes go through DELETE /api/blog/:postId.
export async function updateBlogPostApi(postId: string, body: CreateBlogPostRequest) {
return apiRequest<BlogPostResponse>(ApiRoutes.blog.byId(postId), {
method: "PATCH",
body: JSON.stringify(body),
})
}The server handles slug conflicts through the database constraint. If a duplicate slug is submitted, the API returns a conflict instead of creating an ambiguous URL.
Publishing Checklist
Before publishing a post, check:
- The slug is readable and stable.
- The preview works as a meta description.
- The cover image is public and suitable for social previews.
- SEO keywords are specific to the post.
- Links inside the content are intentional.
- The post URL opens correctly after saving.