npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

rankrunners-cms

v0.0.18

Published

A comprehensive guide for integrating `rankrunners-cms` into TanStack Start websites with a block-based visual editor powered by [Puck](https://puckeditor.com).

Readme

RankRunners CMS Integration Guide

A comprehensive guide for integrating rankrunners-cms into TanStack Start websites with a block-based visual editor powered by Puck.

Table of Contents

  1. Overview
  2. Sample Projects
  3. Installation
  4. Project Structure
  5. Setting Up the Editor
  6. Creating Component Blocks
  7. Creating Page Data
  8. Setting Up Routes
  9. Sitemap & Robots.txt
  10. Best Practices
  11. Developer Checklist
  12. Troubleshooting

1. Overview

rankrunners-cms is a specialized library that provides:

  • Visual Block Editor: Real-time content editing using Puck
  • SEO Management: Automatic meta tags and script injection
  • Reusable Components: Pattern for creating editable component blocks
  • Page Renderer: Automatic page rendering from initial data

Key Concepts

  • Blocks: Editable components with configurable fields
  • Initial Data: Default page content that can be edited in the CMS
  • Root Config: Layout wrapper (Header, Footer) applied to all pages
  • Page Renderer: Component that renders pages based on URL path

2. Sample Projects

Only TypeScript-based projects are supported. JavaScript projects are not supported.

There are two implementations of the CMS support library as of now:

  1. The TanStack Start implementation: This is the recommended implementation. Use this for new projects. Full support for HeaderScripts and FooterScripts.
  2. The Next.js implementation: This implementation exists for legacy sites. There are outstanding issues with the SEO integration. Next.js hydration does not load the script tags in time, and often removes them after hydration. There are many issues with Next.js script tags. HeaderScripts and FooterScripts are completely impossible to implement.

The following projects use the Next.js implementation:

The following projects use the TanStack Start implementation:

3. Installation

Add Dependencies

# Using bun
bun add rankrunners-cms @puckeditor/core

# Using npm
npm install rankrunners-cms @puckeditor/core

Required Peer Dependencies

Ensure you have TanStack Start and Router installed:

bun add @tanstack/react-router @tanstack/react-start

Environment Variables

Add the following to your .env file:

VITE_PUBLIC_SITE_ID=your-site-id

Important: Also add VITE_PUBLIC_SITE_ID to your Dockerfile for production builds.


4. Project Structure

Create the following structure in your src/ directory:

src/
├── editor/
│   ├── index.ts              # Main config export
│   ├── root.tsx              # Root layout (Header/Footer wrapper)
│   ├── types.ts              # TypeScript type definitions
│   ├── blocks/
│   │   ├── main-page-blocks.tsx    # Home page specific blocks
│   │   ├── page-blocks.tsx         # General page blocks
│   │   └── service-page-blocks.tsx # Service-specific blocks, etc.
│   └── initial-data/
│       ├── index.ts               # Exports all page data
│       ├── initial.ts             # Maps paths to page data
│       ├── index-page.ts          # Home page data
│       ├── contact-page.ts        # Contact page data
│       └── ...                    # Other page data files
├── components/
│   ├── Hero.tsx              # React components
│   ├── TrustBar.tsx
│   └── ...
└── routes/
    ├── __root.tsx            # Root route with SEO
    ├── $.ts                  # Catch-all route for CMS pages
    └── ...

5. Setting Up the Editor

5.1 Root Configuration (src/editor/root.tsx)

The root config wraps all pages with your site's Header and Footer:

import { Footer } from '@/components/Footer'
import { Header } from '@/components/Header'
import { ContactUs } from '@/components/ContactUs'
import { ScrollToTopButton } from '@/components/ScrollToTopButton'
import type { DefaultRootProps, RootConfig } from '@puckeditor/core'

export type RootProps = DefaultRootProps

export const Root: RootConfig = {
  defaultProps: {
    title: 'Your Site Title',
  },
  render: ({ children }) => {
    return (
      <>
        <Header />
        {children}
        <ContactUs title="Contact Us" />
        <Footer />
        <ScrollToTopButton />
      </>
    )
  },
}

export default Root

5.2 Type Definitions (src/editor/types.ts)

Define types for your custom components:

import type { CMSUserConfig, CMSUserData } from 'rankrunners-cms/src/editor/types'

import type { MainPageBlockTypes } from './blocks/main-page-blocks'
import type { PageBlockTypes } from './blocks/page-blocks'
import type { ServicePageBlockTypes } from './blocks/service-page-blocks'

// Combine all block types
export type CustomComponents = MainPageBlockTypes & PageBlockTypes & ServicePageBlockTypes

// Export config and data types
export type UserConfig = CMSUserConfig<CustomComponents, ['custom']>
export type UserData = CMSUserData<CustomComponents>

5.3 Main Config (src/editor/index.ts)

Register all blocks and create the config:

import Root from './root'
import type { CustomComponents } from './types'
import { createCMSConfig } from 'rankrunners-cms/src/editor/index'
import { MainPageBlocks } from './blocks/main-page-blocks'
import { PageBlocks } from './blocks/page-blocks'
import { ServicePageBlocks } from './blocks/service-page-blocks'

// Define categories for the editor sidebar
export const customCategories = {
  custom: {
    title: 'Custom',
    components: [
      'HeroBlock',
      'TrustBarBlock',
      'IntroductionBlock',
      'PageTitleBlock',
      'ContactTodayBlock',
      // ... list all block names
    ],
  },
}

// Combine all blocks
export const customComponents = {
  ...MainPageBlocks,
  ...PageBlocks,
  ...ServicePageBlocks,
}

// Create and export the config
export const config = createCMSConfig<CustomComponents, ['custom']>(
  Root,
  customCategories,
  customComponents
)

6. Creating Component Blocks

6.1 Block Naming Convention

Important: All block names must end with Block suffix (e.g., HeroBlock, TrustBarBlock).

6.2 Block Structure Pattern

Each block wraps a React component with Puck configuration:

import type { ComponentConfig } from '@puckeditor/core'
import { withLayout } from 'rankrunners-cms/src/editor/components/Layout'
import { MyComponent, type MyComponentProps } from '@/components/MyComponent'

export const MyComponentBlock: ComponentConfig<MyComponentProps> = withLayout({
  label: 'My Component',      // Display name in editor
  fields: {
    title: { 
      type: 'text', 
      label: 'Title',
      contentEditable: true,  // Allow inline editing
    },
    description: { 
      type: 'textarea', 
      label: 'Description' 
    },
    // ... more fields
  },
  defaultProps: {
    title: 'Default Title',
    description: 'Default description text',
    // ... default values for all props
  },
  render: (props) => <MyComponent {...props} />,
})

6.3 Available Field Types

| Type | Description | Example | |------|-------------|---------| | text | Single-line text input | { type: 'text', label: 'Title' } | | textarea | Multi-line text input | { type: 'textarea', label: 'Description' } | | number | Numeric input | { type: 'number', label: 'Count' } | | select | Dropdown selection | See below | | array | List of items | See below | | object | Nested object | { type: 'object', objectFields: {...} } |

6.4 Select Field Example

category: {
  type: 'select',
  label: 'Category',
  options: [
    { label: 'Repairs', value: 'Repairs' },
    { label: 'Installations', value: 'Installations' },
    { label: 'Remodeling', value: 'Remodeling' },
  ],
},

6.5 Array Field Example

services: {
  type: 'array',
  label: 'Services',
  getItemSummary: (item) => item.name || 'Service',
  arrayFields: {
    name: { type: 'text', label: 'Service Name' },
    description: { type: 'textarea', label: 'Description' },
    imageUrl: { type: 'text', label: 'Image URL' },
  },
  defaultItemProps: {
    name: 'New Service',
    description: '',
    imageUrl: '/assets/placeholder.webp',
  },
},

6.6 The withLayout Wrapper

Critical: Always wrap your block config with withLayout() to enable grid/flex layout support:

// ✅ CORRECT
export const MyBlock: ComponentConfig<MyProps> = withLayout({
  label: 'My Block',
  fields: { ... },
  defaultProps: { ... },
  render: (props) => <MyComponent {...props} />,
})

// ❌ WRONG - Will cause "Element type is invalid" error
export const MyBlock: ComponentConfig<MyProps> = {
  label: 'My Block',
  fields: { ... },
  defaultProps: { ... },
  render: withLayout((props) => <MyComponent {...props} />),  // DON'T do this
}

6.7 Complete Block File Example

// src/editor/blocks/page-blocks.tsx
import type { ComponentConfig } from '@puckeditor/core'
import { withLayout } from 'rankrunners-cms/src/editor/components/Layout'
import { PageTitle, type PageTitleProps } from '@/components/PageTitle'
import { ContactToday, type ContactTodayProps } from '@/components/ContactToday'

// PageTitle Block
export const PageTitleBlock: ComponentConfig<PageTitleProps> = withLayout({
  label: 'Page Title',
  fields: {
    title: { type: 'text', label: 'Title' },
    backgroundImageUrl: { type: 'text', label: 'Background Image URL' },
  },
  defaultProps: {
    title: 'Page Title',
    backgroundImageUrl: '/assets/hero-bg.webp',
  },
  render: (props) => <PageTitle {...props} />,
})

// ContactToday Block
export const ContactTodayBlock: ComponentConfig<ContactTodayProps> = withLayout({
  label: 'Contact Today',
  fields: {
    mainTitle: { type: 'text', label: 'Main Title' },
    phoneNumber: { type: 'text', label: 'Phone Number' },
    // ... more fields
  },
  defaultProps: {
    mainTitle: 'Contact Us Today',
    phoneNumber: '15551234567',
    // ... more defaults
  },
  render: (props) => <ContactToday {...props} />,
})

// Export types for type.ts
export type PageBlockTypes = {
  PageTitleBlock: PageTitleProps
  ContactTodayBlock: ContactTodayProps
}

// Export blocks for index.ts
export const PageBlocks = {
  PageTitleBlock,
  ContactTodayBlock,
}

6.8 Component Design Guidelines

When creating React components for use with the CMS:

  1. Make ALL text configurable via props
  2. Use the same defaults in both the component AND the block
  3. Export the props interface for type safety
// src/components/PageTitle.tsx
import { Link } from '@tanstack/react-router'
import React from 'react'

export interface PageTitleProps {
  title?: string
  backgroundImageUrl?: string
  homeText?: string
}

// Default props - keep in sync with block defaultProps
const defaultProps: Required<PageTitleProps> = {
  title: 'Page Title',
  backgroundImageUrl: '/assets/hero-bg.webp',
  homeText: 'Home',
}

export const PageTitle: React.FC<PageTitleProps> = (props) => {
  const { title, backgroundImageUrl, homeText } = { ...defaultProps, ...props }

  return (
    <div
      className="relative bg-cover bg-center min-h-[15rem] flex justify-center items-center"
      style={{ backgroundImage: `url('\${backgroundImageUrl}')` }}
    >
      <div className="absolute inset-0 bg-black/25" />
      <div className="relative text-center text-white">
        <h1 className="text-4xl uppercase">{title}</h1>
        <div className="mt-2">
          <Link to="/">{homeText}</Link> / {title}
        </div>
      </div>
    </div>
  )
}

7. Creating Page Data

7.1 Page Data Structure

Each page has an initial data file:

// src/editor/initial-data/contact-page.ts
// @ts-nocheck
import type { UserData } from '../types'

export const contactPageData: UserData = {
  root: {
    title: 'Contact Us | Your Site Name',  // Page title for SEO
  },
  content: [
    {
      type: 'PageTitleBlock',  // Must match block name exactly
      props: {
        id: 'page-title',     // Unique ID required
        title: 'Contact Us',
        backgroundImageUrl: '/assets/contact-bg.webp',
        homeText: 'Home',
      },
    },
    {
      type: 'ContactTodayBlock',
      props: {
        id: 'contact-section',
        mainTitle: 'Get In Touch',
        phoneNumber: '15551234567',
        // ... all props for the block
      },
    },
  ],
  zones: {},  // For advanced layouts with drop zones
}

7.2 Registering Page Data (src/editor/initial-data/initial.ts)

Map URL paths to page data:

import { indexPageData } from './index-page'
import { contactPageData } from './contact-page'
import { testimonialsPageData } from './testimonials-page'
import { plumbingPageData } from './services/plumbing-page'

export const allPageData = {
  '': indexPageData,                    // Home page (/)
  contact: contactPageData,             // /contact
  testimonials: testimonialsPageData,   // /testimonials
  'services/plumbing': plumbingPageData, // /services/plumbing
}

export {
  indexPageData,
  contactPageData,
  testimonialsPageData,
  plumbingPageData,
}

7.3 Export Initial Data (src/editor/initial-data/index.ts)

export { allPageData as initialData } from './initial'
export * from './initial'

8. Setting Up Routes

8.1 Root Route (src/routes/__root.tsx)

In your src/routes/__root.tsx, use headWithSEO and seoScripts to initialize the global SEO state.

You can freely configure the head meta, links and scripts if you want.

import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { headWithSEO, seoScripts } from 'rankrunners-cms/src/tanstack'
import appCss from '../styles.css?url'

export const Route = createRootRoute({
  head: headWithSEO<any>(() => ({
    meta: [
      { charSet: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
    ],
    links: [
      { rel: 'stylesheet', href: appCss },
      { rel: 'icon', href: '/favicon.png' },
    ],
  })),
  scripts: seoScripts as any,
  shellComponent: RootDocument,
})

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        {children}
        <Scripts />
      </body>
    </html>
  )
}

8.2 Catch-All Page Route (src/routes/$.ts)

This single route handles ALL CMS pages:

import { config } from '@/editor'
import { initialData } from '@/editor/initial-data'
import { PageRendererTanstack } from 'rankrunners-cms/src/tanstack'
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/\$')({
  component: () =>
    PageRendererTanstack({
      config,
      allPageData: initialData,
    })(),
})

8.3 How the Page Renderer Works

  1. Extracts the pathname from the URL (e.g., /contact -> contact)
  2. Looks up the page data in allPageData
  3. If ?preview=token query param exists, shows the Puck editor
  4. Otherwise, renders the page using the Render component

9. Sitemap & Robots.txt

Update the server.ts script, or add a Tanstack Start middleware to handle [sitemap].xml and robots.txt requests:

  // Build static routes with intelligent preloading
  const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)

  // Add sitemap routes
  routes['/*'] = (req: Request) => {
    const url = new URL(req.url)
    const pathname = url.pathname

    // Serve the sitemap if we are asking for it
    if (pathname === 'robots.txt' || pathname.endsWith('.xml')) {
      const sitemapFile = pathname.substring(1)
      return downloadSitemapAsResponse(sitemapFile)
    }

    // Otherwise, pass to TanStack Start handler
    try {
      return handler.fetch(req)
    } catch (error) {
      log.error(`Server handler error: ${String(error)}`)
      return new Response('Internal Server Error', { status: 500 })
    }
  }

10. Best Practices

Component Design

  • ✅ Make ALL text configurable (no hardcoded strings)
  • ✅ Keep default props in sync between component and block
  • ✅ Export component props interface
  • ✅ Use semantic HTML for accessibility

Block Design

  • ✅ Always use withLayout() wrapper
  • ✅ End block names with Block suffix
  • ✅ Provide meaningful label for editor sidebar
  • ✅ Use contentEditable: true for inline-editable text fields
  • ✅ Use getItemSummary for array fields to show previews

Page Data

  • ✅ Always include unique id in each block's props
  • ✅ Match block type exactly to exported block name

Common Pitfalls

  • ❌ Don't use @measured/puck - it's been renamed to @puckeditor/core
  • ❌ Don't wrap only the render function with withLayout()
  • ❌ Don't use type: 'list' for array fields (use type: 'array')

11. Developer Checklist

Initial Setup

  • [ ] Install rankrunners-cms and @puckeditor/core
  • [ ] Set VITE_PUBLIC_SITE_ID environment variable
  • [ ] Add VITE_PUBLIC_SITE_ID to Dockerfile

Root Route Configuration

  • [ ] Add headWithSEO to createRootRoute
  • [ ] Add seoScripts to createRootRoute
  • [ ] Add <HeadContent /> in <head>
  • [ ] Add <Scripts /> after body content

Editor Structure

  • [ ] Create src/editor/index.ts with config export
  • [ ] Create src/editor/root.tsx with Header/Footer wrapper
  • [ ] Create src/editor/types.ts with type definitions

Block Implementation

  • [ ] Create blocks in src/editor/blocks/
  • [ ] Use withLayout() wrapper for each block
  • [ ] Export block types (e.g., PageBlockTypes)
  • [ ] Export blocks object (e.g., PageBlocks)
  • [ ] Register blocks in customCategories
  • [ ] Register blocks in customComponents

Page Data

  • [ ] Create page data files in src/editor/initial-data/
  • [ ] Register paths in initial.ts allPageData object

Routes

  • [ ] Create catch-all route src/routes/$.ts

Sitemaps

  • [ ] Implement sitemap serving middleware in server.ts

Component Implementation

  • [ ] Migrate/Create components in src/editor/blocks/.
  • [ ] Register all blocks in src/editor/index.tsx.
  • [ ] Organize blocks into categories within the config.

CMS configuration


12. Troubleshooting

"Element type is invalid: expected a string...but got: object"

Cause: Using withLayout() incorrectly.

Fix: Wrap the entire ComponentConfig object, not just the render function:

// ✅ Correct
export const MyBlock = withLayout({
  label: '...',
  fields: {...},
  render: (props) => <MyComponent {...props} />,
})

// ❌ Wrong
export const MyBlock = {
  label: '...',
  fields: {...},
  render: withLayout((props) => <MyComponent {...props} />),
}

Page shows "Loading page..." forever

Cause: Path mismatch between URL and allPageData keys.

Fix: Ensure keys in allPageData match URL paths without leading slashes:

  • URL /contact -> key contact
  • URL /services/plumbing -> key services/plumbing
  • URL / -> key '' (empty string)

Build error: Unexpected character

Cause: Using special characters in strings.

Fix: Replace curly quotes and em-dashes:

  • Curly single quotes -> straight single quote '
  • Curly double quotes -> straight double quote "
  • Em-dash -> hyphen - or double hyphen --

TypeScript error with field type

Cause: Using unsupported field type.

Fix: Use supported types: text, textarea, number, select, array, object


Quick Start Template

Create a new CMS-enabled page in 3 steps:

1. Create the component (src/components/MyPage.tsx)

export interface MyPageProps {
  title?: string
  content?: string
}

export const MyPage: React.FC<MyPageProps> = ({ 
  title = 'Default Title',
  content = 'Default content'
}) => (
  <div className="max-w-4xl mx-auto py-8 px-4">
    <h1>{title}</h1>
    <p>{content}</p>
  </div>
)

2. Create the block (add to src/editor/blocks/page-blocks.tsx)

export const MyPageBlock: ComponentConfig<MyPageProps> = withLayout({
  label: 'My Page',
  fields: {
    title: { type: 'text', label: 'Title' },
    content: { type: 'textarea', label: 'Content' },
  },
  defaultProps: {
    title: 'Default Title',
    content: 'Default content',
  },
  render: (props) => <MyPage {...props} />,
})

3. Create the page data (src/editor/initial-data/mypage-page.ts)

export const mypagePageData: UserData = {
  root: { title: 'My Page | Site Name' },
  content: [
    {
      type: 'MyPageBlock',
      props: { id: 'my-page', title: 'Welcome', content: 'Hello world!' },
    },
  ],
  zones: {},
}

Then register in initial.ts:

export const allPageData = {
  // ...
  'mypage': mypagePageData,
}