You love Jekyll's simplicity but need dynamic features like personalization, A/B testing, or form handling. Cloudflare Workers offer edge computing capabilities, but integrating them with your Jekyll workflow feels disconnected. You're writing Workers in JavaScript while your site is in Ruby/Jekyll, creating context switching and maintenance headaches. The solution is using Ruby gems that bridge this gap, allowing you to develop, test, and deploy Workers using Ruby while seamlessly integrating them with your Jekyll site.
Cloudflare Workers run JavaScript at Cloudflare's edge locations worldwide, allowing you to modify requests and responses. When combined with Jekyll, you get the best of both worlds: Jekyll handles content generation during build time, while Workers handle dynamic aspects at runtime, closer to users. This architecture is called "dynamic static sites" or "Jamstack with edge functions."
The synergy is powerful: Workers can personalize content, handle forms, implement A/B testing, add authentication, and more—all without requiring a backend server. Since Workers run at the edge, they add negligible latency. For Jekyll users, this means you can keep your simple static site workflow while gaining dynamic capabilities. Ruby gems make this integration smoother by providing tools to develop, test, and deploy Workers as part of your Ruby-based Jekyll workflow.
| Worker Function | Benefit for Jekyll | Ruby Integration Approach |
|---|---|---|
| Personalization | Show different content based on visitor attributes | Ruby gem generates Worker config from analytics data |
| A/B Testing | Test content variations without rebuilding | Ruby manages test variations and analyzes results |
| Form Handling | Process forms without third-party services | Ruby gem generates form handling Workers |
| Authentication | Protect private content or admin areas | Ruby manages user accounts and permissions |
| API Composition | Combine multiple APIs into single response | Ruby defines API schemas and response formats |
| Edge Caching Logic | Smart caching beyond static files | Ruby analyzes traffic patterns to optimize caching |
| Bot Detection | Block malicious bots before they reach site | Ruby updates bot signatures and rules |
Several gems facilitate Workers development in Ruby:
gem 'cloudflare-workers'
# Configure client
client = CloudflareWorkers::Client.new(
account_id: ENV['CF_ACCOUNT_ID'],
api_token: ENV['CF_API_TOKEN']
)
# Create a Worker
worker = client.workers.create(
name: 'jekyll-personalizer',
script: ~JS
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
// Your Worker logic here
}
JS
)
# Deploy to route
client.workers.routes.create(
pattern: 'yourdomain.com/*',
script: 'jekyll-personalizer'
)
gem 'wrangler-ruby'
# Run wrangler commands from Ruby
wrangler = Wrangler::CLI.new(
config_path: 'wrangler.toml',
environment: 'production'
)
# Build and deploy
wrangler.build
wrangler.publish
# Manage secrets
wrangler.secret.set('API_KEY', ENV['SOME_API_KEY'])
wrangler.kv.namespace.create('jekyll_data')
wrangler.kv.key.put('trending_posts', trending_posts_json)
While not pure Ruby, you can compile Rust Workers and deploy via Ruby:
gem 'workers-rs'
# Build Rust Worker
worker = WorkersRS::Builder.new('src/worker.rs')
worker.build
# The Rust code (compiles to WebAssembly)
# #[wasm_bindgen]
# pub fn handle_request(req: Request) -> Result {
# // Rust logic here
# }
# Deploy via Ruby
worker.deploy_to_cloudflare
gem 'ruby2js'
# Write Worker logic in Ruby
ruby_code = ~RUBY
add_event_listener('fetch') do |event|
event.respond_with(handle_request(event.request))
end
def handle_request(request)
# Ruby logic here
if request.headers['CF-IPCountry'] == 'US'
# Personalize for US visitors
end
fetch(request)
end
RUBY
# Compile to JavaScript
js_code = Ruby2JS.convert(ruby_code, filters: [:functions, :es2015])
# Deploy
client.workers.create(name: 'ruby-worker', script: js_code)
Create tight integration between Jekyll and Workers:
# _plugins/workers_integration.rb
module Jekyll
class WorkersGenerator < Generator
def generate(site)
# Generate Worker scripts based on site content
generate_personalization_worker(site)
generate_ab_testing_workers(site)
generate_form_handlers(site)
end
def generate_personalization_worker(site)
# Analyze site structure for personalization opportunities
personalized_pages = site.pages.select do |page|
page.data['personalizable'] ||
page.url.include?('blog/') ||
page.data['layout'] == 'post'
end
# Generate Worker that injects personalization
worker_script = ~JS
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const response = await fetch(request)
const country = request.headers.get('CF-IPCountry')
// Clone response to modify
const newResponse = new Response(response.body, response)
// Add personalization header for CSS/JS to use
newResponse.headers.set('X-Visitor-Country', country)
return newResponse
}
JS
# Write to file
File.write('_workers/personalization.js', worker_script)
# Add to site data for deployment
site.data['workers'] ||= []
site.data['workers'] {
name: 'personalization',
script: '_workers/personalization.js',
routes: ['yourdomain.com/*']
}
end
def generate_form_handlers(site)
# Find all forms in site
forms = []
site.pages.each do |page|
content = page.content
if content.include?('
ESI allows dynamic content injection into static pages:
# lib/workers/esi_generator.rb
class ESIGenerator
def self.generate_esi_worker(site)
# Identify dynamic sections in static pages
dynamic_sections = find_dynamic_sections(site)
worker_script = ~JS
import { HTMLRewriter } from 'https://gh.workers.dev/v1.6.0/deno.land/x/html_rewriter@v0.1.0-beta.12/index.js'
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const response = await fetch(request)
const contentType = response.headers.get('Content-Type')
if (!contentType || !contentType.includes('text/html')) {
return response
}
return new HTMLRewriter()
.on('esi-include', {
element(element) {
const src = element.getAttribute('src')
if (src) {
// Fetch and inject dynamic content
element.replace(fetchDynamicContent(src, request), { html: true })
}
}
})
.transform(response)
}
async function fetchDynamicContent(src, originalRequest) {
// Handle different ESI types
switch(true) {
case src.startsWith('/trending'):
return await getTrendingPosts()
case src.startsWith('/personalized'):
return await getPersonalizedContent(originalRequest)
case src.startsWith('/weather'):
return await getWeather(originalRequest)
default:
return 'Dynamic content unavailable'
}
}
async function getTrendingPosts() {
// Fetch from KV store (updated by Ruby script)
const trending = await JEKYLL_KV.get('trending_posts', 'json')
return trending.map(post =>
\`\${post.title} \`
).join('')
}
JS
File.write('_workers/esi.js', worker_script)
end
def self.find_dynamic_sections(site)
# Look for ESI comments or markers
site.pages.flat_map do |page|
content = page.content
# Find patterns
content.scan(//).flatten
end.uniq
end
end
# In Jekyll templates, use:
Inject dynamic content based on real-time data:
# lib/workers/dynamic_content.rb
class DynamicContentWorker
def self.generate_worker(site)
# Generate Worker that injects dynamic content
worker_template = ~JS
addEventListener('fetch', event => {
event.respondWith(injectDynamicContent(event.request))
})
async function injectDynamicContent(request) {
const url = new URL(request.url)
const response = await fetch(request)
// Only process HTML pages
const contentType = response.headers.get('Content-Type')
if (!contentType || !contentType.includes('text/html')) {
return response
}
let html = await response.text()
// Inject dynamic content based on page type
if (url.pathname.includes('/blog/')) {
html = await injectRelatedPosts(html, url.pathname)
html = await injectReadingTime(html)
html = await injectTrendingNotice(html)
}
if (url.pathname === '/') {
html = await injectPersonalizedGreeting(html, request)
html = await injectLatestContent(html)
}
return new Response(html, response)
}
async function injectRelatedPosts(html, currentPath) {
// Get related posts from KV store
const allPosts = await JEKYLL_KV.get('blog_posts', 'json')
const currentPost = allPosts.find(p => p.path === currentPath)
if (!currentPost) return html
const related = allPosts
.filter(p => p.id !== currentPost.id)
.filter(p => hasCommonTags(p.tags, currentPost.tags))
.slice(0, 3)
if (related.length === 0) return html
const relatedHtml = related.map(post =>
\`\`
).join('')
return html.replace(
'',
\`\`
)
}
async function injectPersonalizedGreeting(html, request) {
const country = request.headers.get('CF-IPCountry')
const timezone = request.headers.get('CF-Timezone')
let greeting = 'Welcome'
let extraInfo = ''
if (country) {
const countryName = await getCountryName(country)
greeting = \`Welcome, visitor from \${countryName}\`
}
if (timezone) {
const hour = new Date().toLocaleString('en-US', {
timeZone: timezone,
hour: 'numeric'
})
extraInfo = \` (it's \${hour} o'clock there)\`
}
return html.replace(
'',
\`\${greeting}\${extraInfo}\`
)
}
JS
# Write Worker file
File.write('_workers/dynamic_injection.js', worker_template)
# Also generate Ruby script to update KV store
generate_kv_updater(site)
end
def self.generate_kv_updater(site)
updater_script = ~RUBY
# Update KV store with latest content
require 'cloudflare'
def update_kv_store
cf = Cloudflare.connect(
account_id: ENV['CF_ACCOUNT_ID'],
api_token: ENV['CF_API_TOKEN']
)
# Update blog posts
blog_posts = site.posts.docs.map do |post|
{
id: post.id,
path: post.url,
title: post.data['title'],
excerpt: post.data['excerpt'],
tags: post.data['tags'] || [],
published_at: post.data['date'].iso8601
}
end
cf.workers.kv.write(
namespace_id: ENV['KV_NAMESPACE_ID'],
key: 'blog_posts',
value: blog_posts.to_json
)
# Update trending posts (from analytics)
trending = get_trending_posts_from_analytics()
cf.workers.kv.write(
namespace_id: ENV['KV_NAMESPACE_ID'],
key: 'trending_posts',
value: trending.to_json
)
end
# Run after each Jekyll build
Jekyll::Hooks.register :site, :post_write do |site|
update_kv_store
end
RUBY
File.write('_plugins/kv_updater.rb', updater_script)
end
end
Create a complete testing and deployment workflow:
# Rakefile
namespace :workers do
desc "Build all Workers"
task :build do
puts "Building Workers..."
# Generate Workers from Jekyll site
system("jekyll build")
# Minify Worker scripts
Dir.glob('_workers/*.js').each do |file|
minified = Uglifier.compile(File.read(file))
File.write(file.gsub('.js', '.min.js'), minified)
end
puts "Workers built successfully"
end
desc "Test Workers locally"
task :test do
require 'workers_test'
# Test each Worker
WorkersTest.run_all_tests
# Integration test with Jekyll output
WorkersTest.integration_test
end
desc "Deploy Workers to Cloudflare"
task :deploy do
require 'cloudflare-workers'
client = CloudflareWorkers::Client.new(
account_id: ENV['CF_ACCOUNT_ID'],
api_token: ENV['CF_API_TOKEN']
)
# Deploy each Worker
Dir.glob('_workers/*.min.js').each do |file|
worker_name = File.basename(file, '.min.js')
script = File.read(file)
puts "Deploying #{worker_name}..."
begin
# Update or create Worker
client.workers.create_or_update(
name: worker_name,
script: script
)
# Deploy to routes (from site data)
routes = site.data['workers'].find { |w| w[:name] == worker_name }[:routes]
routes.each do |route|
client.workers.routes.create(
pattern: route,
script: worker_name
)
end
puts "✅ #{worker_name} deployed successfully"
rescue => e
puts "❌ Failed to deploy #{worker_name}: #{e.message}"
end
end
end
desc "Full build and deploy workflow"
task :full do
Rake::Task['workers:build'].invoke
Rake::Task['workers:test'].invoke
Rake::Task['workers:deploy'].invoke
puts "🚀 All Workers deployed successfully"
end
end
# Integrate with Jekyll build
task :build do
# Build Jekyll site
system("jekyll build")
# Build and deploy Workers
Rake::Task['workers:full'].invoke
end
Implement sophisticated edge functionality:
# Worker to collect custom analytics
gem 'cloudflare-workers-analytics'
analytics_worker = ~JS
export default {
async fetch(request, env) {
// Log custom event
await env.ANALYTICS.writeDataPoint({
blobs: [
request.url,
request.cf.country,
request.cf.asOrganization
],
doubles: [1],
indexes: ['pageview']
})
// Continue with request
return fetch(request)
}
}
JS
# Ruby script to query analytics
def get_custom_analytics
client = CloudflareWorkers::Analytics.new(
account_id: ENV['CF_ACCOUNT_ID'],
api_token: ENV['CF_API_TOKEN']
)
data = client.query(
query: {
query: "
SELECT
blob1 as url,
blob2 as country,
SUM(_sample_interval) as visits
FROM jekyll_analytics
WHERE timestamp > NOW() - INTERVAL '1' DAY
GROUP BY url, country
ORDER BY visits DESC
LIMIT 100
"
}
)
data['result']
end
# Worker to optimize images on the fly
image_worker = ~JS
import { ImageWorker } from 'cloudflare-images'
export default {
async fetch(request) {
const url = new URL(request.url)
// Only process image requests
if (!url.pathname.match(/\.(jpg|jpeg|png|webp)$/i)) {
return fetch(request)
}
// Parse optimization parameters
const width = url.searchParams.get('width')
const format = url.searchParams.get('format') || 'webp'
const quality = url.searchParams.get('quality') || 85
// Fetch and transform image
const imageResponse = await fetch(request)
const image = await ImageWorker.load(imageResponse)
if (width) {
image.resize({ width: parseInt(width) })
}
image.format(format)
image.quality(parseInt(quality))
return image.response()
}
}
JS
# Ruby helper to generate optimized image URLs
def optimized_image_url(original_url, width: nil, format: 'webp')
uri = URI(original_url)
params = {}
params[:width] = width if width
params[:format] = format
uri.query = URI.encode_www_form(params)
uri.to_s
end
# Worker for intelligent caching
caching_worker = ~JS
export default {
async fetch(request, env) {
const cache = caches.default
const url = new URL(request.url)
// Try cache first
let response = await cache.match(request)
if (response) {
// Cache hit - check if stale
const age = response.headers.get('age') || 0
if (age < 3600) { // Less than 1 hour old
return response
} else {
// Stale but usable - revalidate in background
event.waitUntil(revalidate(request))
return response
}
}
// Cache miss - fetch and cache
response = await fetch(request)
// Clone response to cache
const responseToCache = response.clone()
// Determine cache TTL based on content type
let ttl = 3600 // Default 1 hour
const contentType = response.headers.get('Content-Type')
if (contentType?.includes('text/html')) {
ttl = 300 // 5 minutes for HTML
} else if (contentType?.includes('image')) {
ttl = 2592000 // 30 days for images
} else if (contentType?.includes('css') || contentType?.includes('js')) {
ttl = 86400 // 1 day for assets
}
// Store in cache
const cacheResponse = new Response(responseToCache.body, responseToCache)
cacheResponse.headers.append('Cache-Control', \`public, max-age=\${ttl}\`)
event.waitUntil(cache.put(request, cacheResponse))
return response
}
}
JS
Start integrating Workers gradually. Begin with a simple personalization Worker that adds visitor country headers. Then implement form handling for your contact form. As you become comfortable, add more sophisticated features like A/B testing and dynamic content injection. Within months, you'll have a Jekyll site with the dynamic capabilities of a full-stack application, all running at the edge with minimal latency.