Blog
Learn how to create, manage, and customize blog posts
ShipSaaS features a built-in blog system built on Content Collections.
Blog System Architecture
The blog system is built using Content Collections, with its collection configuration defined in content-collections.ts at the root of the project.
content
blog/hello-world.mdgetting-started.mddeploy-to-production.md
src
routes/blog/index.tsx$slug.tsx
components/blog/blog-card.tsxblog-grid.tsxblog-pagination.tsx
lib/blog.ts
content-collections.ts
Data Source Configuration
The blog system defines the data collection in the content-collections.ts file using defineCollection:
content-collections.ts
import { defineCollection, defineConfig } from '@content-collections/core';
import { z } from 'zod';
const blog = defineCollection({
name: 'blog',
directory: 'content/blog',
include: '**/*.md',
schema: z.object({
title: z.string(),
description: z.string(),
date: z.string(),
category: z.string(),
content: z.string(),
image: z.url(),
}),
transform: (doc) => ({
...doc,
slug: doc._meta.path,
}),
});
export default defineConfig({
collections: [blog],
});Then, use the data provided by content-collections in src/lib/blog.ts for queries:
src/lib/blog.ts
import { allBlogs } from 'content-collections';
import type { Blog } from 'content-collections';
export type BlogPost = Blog & { slug: string };
export function getSortedPosts(): BlogPost[] {
return [...(allBlogs as BlogPost[])].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
}
export function getPostBySlug(slug: string): BlogPost | undefined {
return (allBlogs as BlogPost[]).find((p) => p.slug === slug);
}
export function getPaginatedPosts(page: number) {
const sorted = getSortedPosts();
// ... pagination logic
}Creating Blog Content
Adding a New Blog Post
Create a new .md file in the content/blog directory:
content/blog/my-first-post.md
---
title: My First Blog Post
description: This is a brief description of my first blog post.
date: 2026-01-15
category: Tutorial
image: https://example.com/images/blog/my-first-post.jpg
---
# Introduction
This is my first blog post. You can use **Markdown** here.
## Section 1
Some content here...
## Section 2
More content here...Frontmatter Field Descriptions
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Article title |
description | string | Yes | Article summary/description |
date | string | Yes | Publication date (Format: YYYY-MM-DD) |
category | string | Yes | Article category (single category) |
image | URL | Yes | Cover image URL |
ShipSaaS's blog uses the .md Markdown file format, and each post has exactly one category (string), rather than a multi-category array.
Routes and Pages
The blog uses the TanStack Router file-based routing system:
src/routes/blog/index.tsx: Blog list page, supporting paginationsrc/routes/blog/$slug.tsx: Blog post details page
src/routes/blog/index.tsx
import { createFileRoute } from '@tanstack/react-router';
import { getPaginatedPosts } from '@/lib/blog';
export const Route = createFileRoute('/blog/')({
loader: ({ location }) => {
const page = Number(new URLSearchParams(location.search).get('page')) || 1;
return getPaginatedPosts(page);
},
component: BlogListPage,
});src/routes/blog/$slug.tsx
import { createFileRoute, notFound } from '@tanstack/react-router';
import { getPostBySlug } from '@/lib/blog';
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params }) => {
const post = getPostBySlug(params.slug);
if (!post) throw notFound();
return post;
},
component: BlogPostPage,
});Customization
Configuring Pagination
Configure the number of posts displayed per page in src/config/website.ts:
src/config/website.ts
export const websiteConfig: WebsiteConfig = {
// ...other config
blog: {
enable: true,
paginationSize: 6,
},
// ...other config
}Changing the Blog Post Card Layout
Customize the blog card component in src/components/blog/blog-card.tsx:
src/components/blog/blog-card.tsx
import type { BlogPost } from '@/lib/blog';
interface BlogCardProps {
post: BlogPost;
}
export function BlogCard({ post }: BlogCardProps) {
return (
<div className="group flex flex-col border rounded-lg overflow-hidden h-full bg-card shadow-sm hover:shadow-md transition-shadow">
<h3>{post.title}</h3>
<p>{post.description}</p>
<span>{post.category}</span>
<time>{post.date}</time>
{/* ... rest of the component */}
</div>
);
}Customizing the Blog Post Data Structure
To add new fields to a blog post:
- Modify the schema in
content-collections.ts - Update your components to display the new field
Example: Adding a "featured" field
content-collections.ts
const blog = defineCollection({
name: 'blog',
directory: 'content/blog',
include: '**/*.md',
schema: z.object({
title: z.string(),
description: z.string(),
date: z.string(),
category: z.string(),
content: z.string(),
image: z.url(),
// Add new field
featured: z.boolean().default(false),
}),
transform: (doc) => ({
...doc,
slug: doc._meta.path,
}),
});Then, you can use this field in your blog posts:
content/blog/important-post.md
---
title: Important Announcement
description: Read this important announcement
date: 2026-01-15
category: Announcement
image: https://example.com/images/blog/announcement.jpg
featured: true
---
Content here...Querying Posts Programmatically
You can use the helper functions in src/lib/blog.ts to query posts:
import { getSortedPosts, getPostBySlug, getPaginatedPosts } from '@/lib/blog';
// Get all posts sorted by date
const allPosts = getSortedPosts();
// Get a specific post by slug
const post = getPostBySlug('hello-world');
// Get paginated posts
const { posts, totalPages, currentPage } = getPaginatedPosts(1);Build Process
The blog system integrates with the Content Collections build process:
- Development: During local development, Content Collections watches the
content/directory for changes and automatically regenerates files. - Build: Vite automatically handles Content Collections during building, so no extra commands are needed.
- Generated Files: The
.content-collectionsdirectory contains the generated TypeScript files.
Because Content Collections integrates directly with Vite, development and build processes are fully automated without requiring any manual execution of extra commands.
Best Practices
- Use High-Quality Images: Use appropriately sized and optimized images for blog posts.
- Consistent Categorization: Keep category names consistent across your list of posts.
- Clear Metadata: Write clear titles and descriptions to enhance SEO efficacy.
- Structured Content: Utilize appropriate headings and paragraphs in your blog post content.
- Date Format: Use the
YYYY-MM-DDdate string format. - Use Zod Validation: Leverage Zod for type-safe content validation.
Next Steps
Now that you know how to use the blog system in ShipSaaS, explore these related topics:
-
Newsletter - Configure email list subscriptions
-
Website Configuration - Configure website settings
-
Deployment - Deploy to Cloudflare Workers