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
- Overview
- Sample Projects
- Installation
- Project Structure
- Setting Up the Editor
- Creating Component Blocks
- Creating Page Data
- Setting Up Routes
- Sitemap & Robots.txt
- Best Practices
- Developer Checklist
- 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:
- The TanStack Start implementation: This is the recommended implementation. Use this for new projects. Full support for
HeaderScriptsandFooterScripts. - 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.
HeaderScriptsandFooterScriptsare 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/coreRequired Peer Dependencies
Ensure you have TanStack Start and Router installed:
bun add @tanstack/react-router @tanstack/react-startEnvironment Variables
Add the following to your .env file:
VITE_PUBLIC_SITE_ID=your-site-idImportant: Also add
VITE_PUBLIC_SITE_IDto 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 Root5.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:
- Make ALL text configurable via props
- Use the same defaults in both the component AND the block
- 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
- Extracts the pathname from the URL (e.g.,
/contact->contact) - Looks up the page data in
allPageData - If
?preview=tokenquery param exists, shows the Puck editor - Otherwise, renders the page using the
Rendercomponent
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
Blocksuffix - ✅ Provide meaningful
labelfor editor sidebar - ✅ Use
contentEditable: truefor inline-editable text fields - ✅ Use
getItemSummaryfor array fields to show previews
Page Data
- ✅ Always include unique
idin each block's props - ✅ Match block
typeexactly 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 (usetype: 'array')
11. Developer Checklist
Initial Setup
- [ ] Install
rankrunners-cmsand@puckeditor/core - [ ] Set
VITE_PUBLIC_SITE_IDenvironment variable - [ ] Add
VITE_PUBLIC_SITE_IDto Dockerfile
Root Route Configuration
- [ ] Add
headWithSEOtocreateRootRoute - [ ] Add
seoScriptstocreateRootRoute - [ ] Add
<HeadContent />in<head> - [ ] Add
<Scripts />after body content
Editor Structure
- [ ] Create
src/editor/index.tswith config export - [ ] Create
src/editor/root.tsxwith Header/Footer wrapper - [ ] Create
src/editor/types.tswith 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.tsallPageDataobject
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
categorieswithin the config.
CMS configuration
- [ ] Added the new pages to the site in the CMS user interface and saved at least once.
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-> keycontact - URL
/services/plumbing-> keyservices/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,
}