LogoShipTanStarter Docs

Blog

Learn how to create, manage, and customize blog posts

ShipTanStarter includes a blog system built on Content Collections.

Blog System Architecture

The blog system is built with Content Collections, with the collection configuration defined in content-collections.ts at the project root.

hello-world.md
getting-started.md
deploy-to-production.md
index.tsx
$slug.tsx
blog-card.tsx
blog-grid.tsx
blog-pagination.tsx
blog.ts
content-collections.ts

Data Source Configuration

The blog system uses defineCollection in the content-collections.ts file to define the data collection:

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 for queries in src/lib/blog.ts:

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 New Blog Posts

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 Fields

FieldTypeRequiredDescription
titlestringYesPost title
descriptionstringYesPost description
datestringYesPublication date (format: YYYY-MM-DD)
categorystringYesPost category (single category)
imageURLYesCover image URL

ShipTanStarter's blog uses .md Markdown files. Each post has a single category (string), not a multi-category array.

Routes and Pages

The blog uses TanStack Router's file-based routing system:

  • src/routes/blog/index.tsx: Blog listing page with pagination
  • src/routes/blog/$slug.tsx: Blog post detail 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 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 Blog 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 Blog Post Data Structure

To add new fields to blog posts:

  1. Modify the schema in content-collections.ts
  2. Update components to display the new fields

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 utility 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 uses the content-collections build process:

  1. Development: During development, content-collections watches the content/ directory for changes and automatically regenerates
  2. Build: Vite automatically processes content-collections during build, no additional commands needed
  3. Generated files: The .content-collections directory contains generated TypeScript files

content-collections is integrated with Vite. Development and build processes are fully automated, no manual commands needed.

Best Practices

  1. Use high-quality images: Use properly sized and optimized images for blog posts
  2. Consistent categories: Maintain consistent category names across posts
  3. Write clear metadata: Write clear titles and descriptions for better SEO
  4. Structured content: Use proper headings and sections in your blog post content
  5. Date format: Use YYYY-MM-DD format for date strings
  6. Use Zod schemas: Use Zod schemas for type-safe content validation

Next Steps

Now that you understand how to work with the blog system in ShipTanStarter, explore these related features:

On this page