Smithery Logo
MCPsSkillsDocsPricing
Login
NewFlame, an assistant that learns and improves. Available onTelegramSlack
    zwlcoding

    ssr-tanstack-strapi

    zwlcoding/ssr-tanstack-strapi
    Coding

    About

    SKILL.md

    Install

    • Telegram
      Telegram
    • Slack
      Slack
    • Claude Code
      Claude Code
    • Codex
      Codex
    • OpenClaw
      OpenClaw
    • Cursor
      Cursor
    • Amp
      Amp
    • GitHub Copilot
      GitHub Copilot
    • Gemini CLI
      Gemini CLI
    • Kilo Code
      Kilo Code
    • Junie
      Junie
    • Replit
      Replit
    • Windsurf
      Windsurf
    • Cline
      Cline
    • Continue
      Continue
    • OpenCode
      OpenCode
    • OpenHands
      OpenHands
    • Roo Code
      Roo Code
    • Augment
      Augment
    • Goose
      Goose
    • Trae
      Trae
    • Zencoder
      Zencoder
    • Antigravity
      Antigravity
    • Download skill
    ├─
    ├─
    └─
    Smithery Logo

    Give agents more agency

    Resources

    DocumentationPrivacy PolicySystem Status

    Company

    PricingAboutBlog

    Connect

    © 2026 Smithery. All rights reserved.

    About

    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...

    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
    2. 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
    3. 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 VITE_STRAPI_URL 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
    Recommended Servers
    MantleKit Launch Planner
    MantleKit Launch Planner
    Sanity
    Sanity
    Airtable
    Airtable
    Repository
    zwlcoding/skills
    Files