Blog
ShipSaaS features a built-in blog system built on Content Collections.
Blog System Architecture
Section titled “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
Section titled “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
Section titled “Creating Blog Content”Adding a New Blog Post
Section titled “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 Postdescription: This is a brief description of my first blog post.date: 2026-01-15category: Tutorialimage: 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
Section titled “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
Section titled “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
Section titled “Customization”Configuring Pagination
Section titled “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
Section titled “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
Section titled “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 Announcementdescription: Read this important announcementdate: 2026-01-15category: Announcementimage: https://example.com/images/blog/announcement.jpgfeatured: true---
Content here...Querying Posts Programmatically
Section titled “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 dateconst allPosts = getSortedPosts();
// Get a specific post by slugconst post = getPostBySlug('hello-world');
// Get paginated postsconst { posts, totalPages, currentPage } = getPaginatedPosts(1);Build Process
Section titled “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
Section titled “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
Section titled “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