LogoShipSaaS

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.md
    • getting-started.md
    • deploy-to-production.md

src

  • routes/
    • blog/
      • index.tsx
      • $slug.tsx
  • components/
    • blog/
      • blog-card.tsx
      • blog-grid.tsx
      • blog-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

FieldTypeRequiredDescription
titlestringYesArticle title
descriptionstringYesArticle summary/description
datestringYesPublication date (Format: YYYY-MM-DD)
categorystringYesArticle category (single category)
imageURLYesCover 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 pagination
  • src/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:

  1. Modify the schema in content-collections.ts
  2. 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:

  1. Development: During local development, Content Collections watches the content/ directory for changes and automatically regenerates files.
  2. Build: Vite automatically handles Content Collections during building, so no extra commands are needed.
  3. Generated Files: The .content-collections directory 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

  1. Use High-Quality Images: Use appropriately sized and optimized images for blog posts.
  2. Consistent Categorization: Keep category names consistent across your list of posts.
  3. Clear Metadata: Write clear titles and descriptions to enhance SEO efficacy.
  4. Structured Content: Utilize appropriate headings and paragraphs in your blog post content.
  5. Date Format: Use the YYYY-MM-DD date string format.
  6. 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:

On this page