smithery/zwlcoding

ssr-tanstack-strapi

Build SSR applications with TanStack Start and Strapi CMS.

Installation

$ npx skills add smithery/zwlcoding --skill ssr-tanstack-strapi

Summary

  • Build SSR applications with TanStack Start and Strapi CMS.
  • Use when creating content-driven websites with server-side rendering, SEO requirements, and headless CMS integration.
  • Covers monorepo setup, TanStack Query integration, type-safe API clients, and production-ready patterns.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 15,255 B
  • docs SUMMARY.md 308 B

History

  1. First recorded snapshot · 0 installs

SKILL.md

SSR TanStack Strapi

Build production-ready SSR applications using TanStack Start (React) frontend with Strapi headless CMS backend.

When to Use

  • Building content-driven websites (blogs, wikis, documentation sites)
  • SEO-critical applications requiring server-side rendering
  • Projects needing a headless CMS for content management
  • Multi-entity applications with complex relationships
  • Applications requiring search, filtering, and pagination

Tech Stack

Frontend

  • Framework: TanStack Start (React-based full-stack framework)
  • Routing: TanStack Router (file-based)
  • SSR: Nitro-based server-side rendering
  • Data Fetching: TanStack Query with SSR support
  • HTTP Client: Native fetch + @strapi/client
  • Build Tool: Vite
  • Language: TypeScript

Backend

  • CMS: Strapi 5.x (headless)
  • Database: PostgreSQL (recommended for production)
  • API: REST with auto-generated endpoints

Monorepo

  • Package Manager: pnpm with workspaces
  • Structure: frontend/ + backend/ in single repo

Project Structure

my-app/
├── frontend/                    # TanStack Start application
│   ├── src/
│   │   ├── routes/              # File-based routing
│   │   │   ├── __root.tsx       # Root layout
│   │   │   ├── index.tsx        # Home page
│   │   │   └── posts/
│   │   │       ├── index.tsx    # List page
│   │   │       └── $slug.tsx    # Detail page
│   │   ├── hooks/               # TanStack Query hooks
│   │   ├── lib/
│   │   │   └── strapiClient.ts  # API client
│   │   ├── types/
│   │   │   └── strapi.ts        # TypeScript types
│   │   └── components/          # UI components
│   ├── app.config.ts            # TanStack Start config
│   └── package.json
├── backend/                     # Strapi application
│   └── src/api/                 # Content types
└── pnpm-workspace.yaml

Quick Start

Before starting, ask the user:

"Do you want to install the project in the current directory? (yes/no)"

  • If YES: Create frontend/ and backend/ directly in the current directory
  • If NO: Create a new project directory first, then create frontend/ and backend/ inside it

1. Initialize Monorepo

If installing in current directory:

# Create pnpm workspace in current directory
cat > pnpm-workspace.yaml << 'EOF'
packages:
  - 'frontend'
  - 'backend'
EOF

If creating new project directory:

# Ask user for project name, default to 'my-ssr-app'
read -p "Project name (default: my-ssr-app): " project_name
project_name=${project_name:-my-ssr-app}
mkdir "$project_name" && cd "$project_name"

# Create pnpm workspace
cat > pnpm-workspace.yaml << 'EOF'
packages:
  - 'frontend'
  - 'backend'
EOF

2. Initialize Frontend (TanStack Start)

Use the new TanStack CLI with pnpm:

# Interactive mode (recommended) - guides you through project setup
pnpm dlx @tanstack/cli create frontend

# Or non-interactive with defaults
pnpm dlx @tanstack/cli create frontend -y

# With add-ons (optional)
pnpm dlx @tanstack/cli create frontend --add-ons tanstack-query

Note: The CLI will create a project in the frontend/ directory. After creation:

  • Set up React + TypeScript
  • Configure Vite + TanStack Router
  • Set up file-based routing
  • Configure development server

Run the project:

cd frontend
pnpm dev
# Open http://localhost:3000

3. Initialize Backend (Strapi)

Use pnpm with the new Strapi CLI:

# Create Strapi project (interactive mode)
pnpm dlx create-strapi@latest backend

# Or with options
pnpm dlx create-strapi@latest backend --template <template-name>

Note: The CLI will create a project in the backend/ directory. After creation:

  • Set up Strapi with TypeScript
  • Configure database (SQLite by default for development)
  • Set up admin panel
  • Configure API endpoints

Run the project:

cd backend
pnpm dev
# Open http://localhost:1337/admin

4. Install Additional Dependencies

Frontend dependencies:

cd frontend

# Strapi client SDK
pnpm add @strapi/client

# TanStack Query for data fetching
pnpm add @tanstack/react-query

# Optional: Zustand for state management
pnpm add zustand

# Optional: DOMPurify for safe HTML rendering
pnpm add dompurify
pnpm add -D @types/dompurify

Backend dependencies:

cd backend

# PostgreSQL driver (for production)
pnpm add pg

# Or SQLite for development
pnpm add better-sqlite3

Core Configuration

1. TanStack Start Config

Create/Update frontend/app.config.ts:

import { defineConfig } from '@tanstack/react-start/config';
import tsConfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  vite: {
    plugins: [
      tsConfigPaths({
        projects: ['./tsconfig.json'],
      }),
    ],
  },
});

2. Strapi Client Setup

Create frontend/src/lib/strapiClient.ts:

import { strapi } from '@strapi/client';

const STRAPI_URL = import.meta.env.VITE_STRAPI_URL || 'http://localhost:1337';

// Strapi SDK client
export const strapiClient = strapi({
  baseURL: STRAPI_URL,
});

// Type-safe fetch wrapper
export async function strapiFetch<T>(
  endpoint: string,
  options?: RequestInit
): Promise<T> {
  const url = `${STRAPI_URL}/api${endpoint}`;
  
  const response = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...options?.headers,
    },
  });

  if (!response.ok) {
    throw new Error(`API error: ${response.statusText}`);
  }

  return response.json();
}

3. TypeScript Types

Create frontend/src/types/strapi.ts:

export interface StrapiResponse<T> {
  data: T;
  meta?: {
    pagination?: {
      page: number;
      pageSize: number;
      pageCount: number;
      total: number;
    };
  };
}

export interface StrapiImage {
  id: number;
  name: string;
  url: string;
  formats?: {
    thumbnail?: { url: string };
    small?: { url: string };
    medium?: { url: string };
    large?: { url: string };
  };
}

export interface Post {
  id: number;
  documentId: string;
  title: string;
  slug: string;
  content: string;
  excerpt?: string;
  featuredImage?: StrapiImage;
  publishedAt?: string;
  createdAt: string;
  updatedAt: string;
}

export interface Category {
  id: number;
  documentId: string;
  name: string;
  slug: string;
  description?: string;
}

Core Patterns

1. TanStack Query Hooks

Create frontend/src/hooks/usePosts.ts:

import { useQuery } from '@tanstack/react-query';
import { strapiFetch } from '@/lib/strapiClient';
import type { Post, StrapiResponse } from '@/types/strapi';

export function usePosts(filters?: {
  category?: string;
  search?: string;
}) {
  return useQuery({
    queryKey: ['posts', filters],
    queryFn: async () => {
      const params = new URLSearchParams();
      
      if (filters?.category && filters.category !== 'all') {
        params.append('filters[category][slug][$eq]', filters.category);
      }
      
      if (filters?.search) {
        params.append('filters[title][$containsi]', filters.search);
      }
      
      params.append('populate', '*');
      params.append('sort[0]', 'publishedAt:desc');
      
      return strapiFetch<StrapiResponse<Post[]>>(`/posts?${params}`);
    },
  });
}

export function usePost(slug: string) {
  return useQuery({
    queryKey: ['post', slug],
    queryFn: async () => {
      const params = new URLSearchParams();
      params.append('filters[slug][$eq]', slug);
      params.append('populate', '*');
      
      const response = await strapiFetch<StrapiResponse<Post[]>>(
        `/posts?${params}`
      );
      
      if (!response.data.length) {
        throw new Error('Post not found');
      }
      
      return response.data[0];
    },
  });
}

2. Route with SSR Data Fetching

// frontend/src/routes/posts/$slug.tsx
import { createFileRoute } from '@tanstack/react-router';
import { usePost } from '@/hooks/usePosts';

export const Route = createFileRoute('/posts/$slug')({
  component: PostDetailPage,
  loader: async ({ params, context }) => {
    // Prefetch for SSR
    await context.queryClient.prefetchQuery({
      queryKey: ['post', params.slug],
      queryFn: () => fetchPost(params.slug),
    });
    return { slug: params.slug };
  },
  head: ({ loaderData }) => ({
    meta: [
      { title: `Post: ${loaderData.slug}` },
    ],
  }),
});

function PostDetailPage() {
  const { slug } = Route.useLoaderData();
  const { data: post, isLoading, error } = usePost(slug);
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!post) return <div>Not found</div>;
  
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

3. List Page with Filtering

// frontend/src/routes/posts/index.tsx
import { createFileRoute, useSearch } from '@tanstack/react-router';
import { usePosts } from '@/hooks/usePosts';

export const Route = createFileRoute('/posts/')({
  component: PostsListPage,
  validateSearch: (search) => ({
    category: (search.category as string) || 'all',
    q: (search.q as string) || '',
  }),
});

function PostsListPage() {
  const { category, q } = useSearch({ from: '/posts/' });
  const { data, isLoading } = usePosts({ category, search: q });
  const navigate = useNavigate();
  
  return (
    <div>
      <input
        type="search"
        value={q}
        onChange={(e) => navigate({
          search: (prev) => ({ ...prev, q: e.target.value }),
        })}
        placeholder="Search posts..."
      />
      
      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <div className="grid">
          {data?.data.map((post) => (
            <Link
              key={post.id}
              to="/posts/$slug"
              params={{ slug: post.slug }}
            >
              <h3>{post.title}</h3>
            </Link>
          ))}
        </div>
      )}
    </div>
  );
}

4. Image Component with Lazy Loading

// frontend/src/components/StrapiImage.tsx
import { useState } from 'react';
import type { StrapiImage as StrapiImageType } from '@/types/strapi';

interface StrapiImageProps {
  image: StrapiImageType;
  alt: string;
  size?: 'thumbnail' | 'small' | 'medium' | 'large';
  className?: string;
}

export function StrapiImage({ 
  image, 
  alt, 
  size = 'medium',
  className 
}: StrapiImageProps) {
  const [loaded, setLoaded] = useState(false);
  const [error, setError] = useState(false);
  
  const imageUrl = image.formats?.[size]?.url || image.url;
  const fullUrl = `${import.meta.env.VITE_STRAPI_URL}${imageUrl}`;
  
  if (error) {
    return <div className="bg-gray-200" />;
  }
  
  return (
    <img
      src={fullUrl}
      alt={alt}
      loading="lazy"
      className={`transition-opacity ${loaded ? 'opacity-100' : 'opacity-0'} ${className}`}
      onLoad={() => setLoaded(true)}
      onError={() => setError(true)}
    />
  );
}

5. Safe HTML Rendering

// frontend/src/components/SafeHTML.tsx
import DOMPurify from 'dompurify';

interface SafeHTMLProps {
  html: string;
  className?: string;
}

export function SafeHTML({ html, className }: SafeHTMLProps) {
  const sanitized = DOMPurify.sanitize(html);
  
  return (
    <div 
      className={className}
      dangerouslySetInnerHTML={{ __html: sanitized }}
    />
  );
}

Strapi Content Types Setup

Example Content Types

  1. Category

- name: Text (required) - slug: UID (based on name) - description: Rich Text

  1. Post (main content)

- title: Text (required) - slug: UID (based on title) - content: Rich Text - excerpt: Text - category: Relation (Many-to-One) - featuredImage: Media - publishedAt: DateTime

  1. Tag

- name: Text (required) - slug: UID

API Permissions

In Strapi Admin:

  1. Settings > Users & Permissions Plugin > Roles > Public
  2. Enable find and findOne for each content type
  3. Save

Environment Configuration

Frontend (.env.local)

VITE_STRAPI_URL=http://localhost:1337

Backend (.env)

# Database (PostgreSQL for production)
DATABASE_CLIENT=postgres
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=password
DATABASE_SSL=false

# JWT Secrets
JWT_SECRET=your-jwt-secret
ADMIN_JWT_SECRET=your-admin-jwt-secret
APP_KEYS=key1,key2,key3,key4
API_TOKEN_SALT=your-api-token-salt
TRANSFER_TOKEN_SALT=your-transfer-token-salt

Development Workflow

# Terminal 1: Start Strapi
cd backend
pnpm dev

# Terminal 2: Start TanStack Start
cd frontend
pnpm dev

# Access:
# - Frontend: http://localhost:3000
# - Strapi Admin: http://localhost:1337/admin

Production Deployment

Build Commands

# Build frontend
cd frontend
pnpm build

# Build Strapi
cd backend
pnpm build

Production Checklist

  • Configure production database (PostgreSQL)
  • Set up environment variables
  • Configure CORS in Strapi
  • Set up image CDN (Cloudinary/AWS S3)
  • Configure SSL certificates
  • Set up monitoring and logging

Common Patterns

Pagination

const { data } = useQuery({
  queryKey: ['posts', page],
  queryFn: () => strapiFetch(
    `/posts?pagination[page]=${page}&pagination[pageSize]=10`
  ),
});

Sorting

strapiFetch('/posts?sort[0]=publishedAt:desc')

Deep Population

strapiFetch('/posts?populate[category][populate]=*&populate[author][populate]=*')

Search with Filters

const params = new URLSearchParams();
params.append('filters[$or][0][title][$containsi]', query);
params.append('filters[$or][1][content][$containsi]', query);
strapiFetch(`/posts?${params}`);

Troubleshooting

Issue Solution
CORS errors Configure cors in Strapi config
Hydration mismatch Ensure SSR and client render same data
Images not loading Check VITESTRAPIURL includes protocol
Type errors Regenerate types after Strapi schema changes
Query not refetching Check queryKey includes all dependencies

Best Practices

  1. **Always use populate=*** to get related data
  2. Use documentId for stable identifiers (Strapi 5+)
  3. Implement error boundaries for route errors
  4. Use useSuspenseQuery for SSR-friendly loading states
  5. Cache API responses with appropriate staleTime
  6. Implement search debouncing (300-500ms)
  7. Use image formats for responsive images
  8. Sanitize HTML before rendering rich text