Spacerr
  • Features
  • Pricing
  • FAQ
  • Docs
Get Access
Spacerr
  • Introduction
  • Features
  • Tech Stack
  • Setup
  • Configuration
  • Agents
  • Database
  • Jobs
  • Admin
  • Settings
  • Billing
  • Storage
  • Email
  • Support
  • Localization
    • Overview
    • Publishing
    • Blog SEO
  • SEO
  • Analytics
  • UI And Navigation
  • Deploying To Production
  • Testing And QA
  • Troubleshooting

Search documentation

Search documentation pages.

Documentation/Blog Publishing

Blog Publishing

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.

txt
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.

txt
/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.tsx
  • src/features/blog/components/blog-create-post-card.tsx
  • src/features/blog/components/edit-blog-post-page.tsx
  • src/features/blog/components/blog-post-form.tsx
  • src/features/blog/components/blog-content-editor.tsx

Post Fields

The create schema lives in src/features/blog/schemas/create-blog-post.schema.ts.

typescript
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:

  • title for the post heading and metadata title.
  • slug for the public URL.
  • preview for cards, RSS descriptions, and meta descriptions.
  • seoKeywords for metadata keywords and RSS categories.
  • imageSrc for the cover image and social preview image.
  • content for sanitized rich content stored as JSON.

Cover Uploads

Cover image uploads go through src/app/api/blog/upload-cover/route.ts.

typescript
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.

typescript
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:

  1. The admin or moderator opens /blog/create.
  2. The editor collects title, slug, preview, keywords, cover image, and content.
  3. Cover images are uploaded to Vercel Blob.
  4. The form posts to POST /api/blog.
  5. The server validates the body with Zod.
  6. The server sanitizes the content.
  7. The post is written to the database.
  8. The blog cache tag is revalidated.
  9. The user is sent to the published post.
txt
revalidateTag(BLOG_POSTS_CACHE_TAG, "max")

Update And Delete

Updates go through PATCH /api/blog/:postId. Deletes go through DELETE /api/blog/:postId.

typescript
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.
Blog

Learn how the blog is structured in Spacerr.

Blog SEO

Learn how blog metadata, RSS, sitemap, JSON LD, and social previews work in Spacerr.

On this page
Publishing AccessAdmin PagesPost FieldsCover UploadsContent SanitizingCreate FlowUpdate And DeletePublishing Checklist