We're using Strapi headless CMS instead of building a custom CMS from scratch. This provides:
- ✅ Production-ready admin panel
- ✅ Built-in drag & drop content builder
- ✅ User authentication & permissions
- ✅ Media library
- ✅ API generation
- ✅ PostgreSQL support
- ✅ Docker ready
- ✅ Well maintained & documented
Original Plan: Custom Express + TypeScript backend with custom admin panel using Tabler template (~2 weeks development)
Final Decision: Strapi CMS (~2-3 days integration)
Rationale:
- Faster time to market
- Production-tested and battle-hardened
- Active community and regular updates
- All required features out of the box
- Lower maintenance burden
- Better security (regular patches)
- React 18 + TypeScript + Vite
- Tailwind CSS
- React Router v6
- Framer Motion
- NEW: React DnD or dnd-kit (for drag-and-drop)
- NEW: React Markdown (for markdown rendering)
- Node.js + Express + TypeScript
- JWT authentication
- Bcrypt for password hashing
- Multer for file uploads
- CORS enabled
- Tabler Admin Template (HTML/CSS/JS)
- Integrated with React or standalone HTML pages
- Authentication protected routes
- PostgreSQL 15+ (for structured data)
- Tables: users, pages, page_sections, settings, media
- Docker + Docker Compose
- Nginx as reverse proxy
- Persistent volumes for database and uploads
- Hot reload in development mode
-- Users table
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255),
role VARCHAR(50) DEFAULT 'admin',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Settings table (key-value store)
CREATE TABLE settings (
id SERIAL PRIMARY KEY,
key VARCHAR(255) UNIQUE NOT NULL,
value TEXT,
type VARCHAR(50) DEFAULT 'string',
updated_at TIMESTAMP DEFAULT NOW()
);
-- Pages table
CREATE TABLE pages (
id SERIAL PRIMARY KEY,
route VARCHAR(255) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
meta_description TEXT,
is_published BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Page sections table (for drag-and-drop components)
CREATE TABLE page_sections (
id SERIAL PRIMARY KEY,
page_id INTEGER REFERENCES pages(id) ON DELETE CASCADE,
section_type VARCHAR(100) NOT NULL,
content JSONB,
position INTEGER NOT NULL,
is_visible BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Media table
CREATE TABLE media (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255),
mime_type VARCHAR(100),
size INTEGER,
url TEXT NOT NULL,
uploaded_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);jobrythm.com/
├── docker-compose.yml
├── .env.example
├── .dockerignore
├── .gitignore
│
├── frontend/ # React frontend
│ ├── Dockerfile
│ ├── nginx.conf
│ ├── package.json
│ ├── vite.config.ts
│ ├── src/
│ │ ├── components/
│ │ │ ├── layout/
│ │ │ ├── ui/
│ │ │ └── dynamic/ # NEW: Dynamic section components
│ │ ├── pages/
│ │ │ ├── admin/ # NEW: Admin panel pages
│ │ │ └── public/ # Public facing pages
│ │ ├── services/ # NEW: API client
│ │ ├── contexts/ # NEW: Auth context
│ │ └── utils/
│ └── public/
│
├── backend/ # NEW: Express backend
│ ├── Dockerfile
│ ├── package.json
│ ├── tsconfig.json
│ ├── src/
│ │ ├── server.ts
│ │ ├── config/
│ │ │ └── database.ts
│ │ ├── middleware/
│ │ │ ├── auth.ts
│ │ │ └── errorHandler.ts
│ │ ├── models/
│ │ │ ├── User.ts
│ │ │ ├── Page.ts
│ │ │ ├── PageSection.ts
│ │ │ └── Setting.ts
│ │ ├── routes/
│ │ │ ├── auth.ts
│ │ │ ├── pages.ts
│ │ │ ├── sections.ts
│ │ │ ├── settings.ts
│ │ │ └── media.ts
│ │ ├── controllers/
│ │ └── utils/
│ └── uploads/ # Persistent volume mount
│
└── database/ # PostgreSQL init scripts
└── init.sql
- POST
/api/auth/login- Admin login - POST
/api/auth/logout- Admin logout - GET
/api/auth/me- Get current user - POST
/api/auth/change-password- Change password
- GET
/api/pages- List all pages - GET
/api/pages/:id- Get page by ID - GET
/api/pages/route/:route- Get page by route (for frontend) - POST
/api/pages- Create new page - PUT
/api/pages/:id- Update page - DELETE
/api/pages/:id- Delete page - PUT
/api/pages/:id/publish- Publish/unpublish page
- GET
/api/pages/:pageId/sections- Get page sections - POST
/api/pages/:pageId/sections- Add section to page - PUT
/api/sections/:id- Update section - DELETE
/api/sections/:id- Delete section - PUT
/api/sections/reorder- Reorder sections (drag-and-drop)
- GET
/api/settings- Get all settings - GET
/api/settings/:key- Get setting by key - PUT
/api/settings/:key- Update setting - POST
/api/settings- Create new setting
- POST
/api/media/upload- Upload file - GET
/api/media- List all media - DELETE
/api/media/:id- Delete media file
- Hero - Hero section with headline, subheadline, CTAs
- Features Grid - Grid of feature cards
- Pricing - Pricing tables
- Testimonials - Customer testimonials
- CTA Band - Call-to-action banner
- Rich Text - Markdown content
- Image - Single image with caption
- Image Gallery - Multiple images
- Video - Embedded video
- FAQ - Accordion FAQ section
- Contact Form - Form builder
- Custom HTML - Raw HTML/React component
{
"id": 1,
"section_type": "hero",
"content": {
"headline": "Win more work. Protect your margins.",
"subheadline": "Quoting, job costing, invoicing...",
"primaryCTA": {
"text": "Start free",
"url": "{{APP_URL}}/signup"
},
"secondaryCTA": {
"text": "Book demo",
"url": "/book-demo"
},
"backgroundImage": "/uploads/hero-bg.jpg"
},
"position": 0,
"is_visible": true
}version: '3.8'
services:
# PostgreSQL Database
database:
image: postgres:15-alpine
environment:
POSTGRES_DB: jobrythm_cms
POSTGRES_USER: jobrythm
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
networks:
- jobrythm_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U jobrythm"]
interval: 10s
timeout: 5s
retries: 5
# Backend API
backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
NODE_ENV: production
PORT: 3000
DATABASE_URL: postgresql://jobrythm:${DB_PASSWORD}@database:5432/jobrythm_cms
JWT_SECRET: ${JWT_SECRET}
DEFAULT_ADMIN_EMAIL: admin@jobrythm.com
DEFAULT_ADMIN_PASSWORD: adminpassword
volumes:
- ./backend/uploads:/app/uploads
ports:
- "3000:3000"
depends_on:
database:
condition: service_healthy
networks:
- jobrythm_network
# Frontend (React + Nginx)
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
environment:
VITE_API_URL: http://backend:3000
ports:
- "80:80"
depends_on:
- backend
networks:
- jobrythm_network
volumes:
postgres_data:
driver: local
networks:
jobrythm_network:
driver: bridge- Overview statistics
- Recent pages
- Quick actions
- List all pages (table view)
- Create/edit/delete pages
- Publish/unpublish toggle
- SEO settings per page
- Left sidebar: Available sections
- Center: Preview with drag handles
- Right sidebar: Section editor (markdown, settings)
- Real-time preview
- Save draft / Publish
- App Domain: Configure app.jobrythm.com URL
- Site title, description
- Logo upload
- Social media links
- Contact email
- Analytics IDs
- Upload images/files
- Grid view with thumbnails
- Search and filter
- Delete unused media
- Change password
- Update profile info
# Database
DB_PASSWORD=your_secure_password_here
# Backend
JWT_SECRET=your_jwt_secret_here
DEFAULT_ADMIN_EMAIL=admin@jobrythm.com
DEFAULT_ADMIN_PASSWORD=adminpassword
# Frontend
VITE_API_URL=http://localhost:3000- Create backend directory structure
- Set up Express + TypeScript
- Configure PostgreSQL connection
- Create database models
- Implement authentication (JWT)
- Create API routes
- Add seed script for default admin
- Create init.sql with schema
- Write docker-compose.yml
- Create Dockerfiles for frontend/backend
- Test docker-compose up
- Verify persistent volumes
- Download Tabler Admin template
- Create admin routes in React
- Integrate Tabler HTML/CSS
- Build admin pages (Dashboard, Pages, Settings)
- Implement authentication flow
- Install react-dnd or dnd-kit
- Create section components
- Build drag-and-drop interface
- Implement markdown editor
- Add section CRUD operations
- Real-time preview
- Create API client service
- Update pages to fetch dynamic content
- Render sections dynamically
- Handle {{APP_URL}} placeholder replacement
- Add loading states
- Create settings API
- Build settings UI in admin
- Implement domain configuration
- Add settings context in frontend
- Use settings throughout site
- Test full docker workflow
- Test data persistence
- Test admin features
- Validate security
- Performance optimization
- Extract existing page content to JSON
- Create seed script to populate database
- Map static components to section types
- Import initial content via API
- Keep existing React components
- Add dynamic rendering layer
- Gradual migration page by page
- Authentication: JWT tokens, HTTP-only cookies
- Authorization: Role-based access (admin only)
- Input Validation: Sanitize all inputs
- SQL Injection: Use parameterized queries
- XSS Protection: Sanitize markdown output
- CSRF Protection: CSRF tokens for forms
- Rate Limiting: API rate limits
- File Uploads: Validate file types and sizes
- Caching: Redis for page cache (future)
- CDN: Static assets via CDN
- Database Indexes: Index frequently queried fields
- Image Optimization: Compress uploads
- Lazy Loading: Lazy load sections
- API request logging
- Error tracking (Sentry integration)
- Database query logging
- Admin action audit log
- Multi-language support
- A/B testing sections
- Analytics dashboard
- Email templates
- Webhooks for deployments
- Version control for pages
- User roles (editor, viewer)
- API documentation (Swagger)
- Phase 1: Backend Setup - 2-3 days
- Phase 2: Database & Docker - 1 day
- Phase 3: Admin Panel - 2-3 days
- Phase 4: Page Builder - 3-4 days
- Phase 5: Frontend Updates - 2 days
- Phase 6: Settings - 1 day
- Phase 7: Testing - 1-2 days
Total: ~2 weeks for full implementation
- This transforms a simple marketing site into a full CMS
- Significant increase in complexity and maintenance
- Consider hosting costs (database, storage)
- Backup strategy needed for production
- Consider using existing CMS (Strapi, Payload) if timeline is critical