← back to Ventura Claw Leads
feat(seo): per-business dynamic Open Graph images
d818054413cbff08a409fe64eab9be0dd06a370f · 2026-05-07 08:34:08 -0700 · Steve Abrams
- new lib/og-image.js builds 1200x630 SVG (vertical-keyed gradient + business
name + neighborhood + venturaclaw wordmark) and renders to PNG via sharp
- new GET /business/:slug/og.png route (24h immutable cache, 404 on bad slug)
- business page passes ogImage = photo_path absolute URL OR /og.png fallback
- head.ejs already supports ogImage; business.ejs include block now resolves
the absolute URL at the route layer rather than in the template
- 92KB output, 46/46 tests stable, deployed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A lib/og-image.jsM routes/public.jsM views/public/business.ejs
Diff
commit d818054413cbff08a409fe64eab9be0dd06a370f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 7 08:34:08 2026 -0700
feat(seo): per-business dynamic Open Graph images
- new lib/og-image.js builds 1200x630 SVG (vertical-keyed gradient + business
name + neighborhood + venturaclaw wordmark) and renders to PNG via sharp
- new GET /business/:slug/og.png route (24h immutable cache, 404 on bad slug)
- business page passes ogImage = photo_path absolute URL OR /og.png fallback
- head.ejs already supports ogImage; business.ejs include block now resolves
the absolute URL at the route layer rather than in the template
- 92KB output, 46/46 tests stable, deployed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
lib/og-image.js | 94 +++++++++++++++++++++++++++++++++++++++++++++++
routes/public.js | 40 +++++++++++++++++++-
views/public/business.ejs | 4 +-
3 files changed, 134 insertions(+), 4 deletions(-)
diff --git a/lib/og-image.js b/lib/og-image.js
new file mode 100644
index 0000000..4ce2a53
--- /dev/null
+++ b/lib/og-image.js
@@ -0,0 +1,94 @@
+// Per-business Open Graph image generator. Renders a 1200x630 PNG with the
+// business name in serif over the vertical's gradient banner, plus
+// neighborhood and the venturaclaw.com wordmark.
+//
+// Render path: SVG -> sharp PNG. No external services, no fonts to ship —
+// SVG uses generic font-family fallback chains so the image renders fine
+// without our webfont stack.
+
+let sharp;
+try { sharp = require('sharp'); } catch (e) {
+ console.warn('[og-image] sharp not available; OG endpoint will 404');
+}
+
+const VERTICAL_GRADIENT = {
+ food: { from: '#fef3c7', to: '#f59e0b' },
+ beauty: { from: '#fce7f3', to: '#ec4899' },
+ retail: { from: '#ede9fe', to: '#8b5cf6' },
+ fitness: { from: '#d1fae5', to: '#10b981' },
+ pet_services: { from: '#fed7aa', to: '#f97316' },
+ auto_detail: { from: '#dbeafe', to: '#3b82f6' },
+ cleaning: { from: '#cffafe', to: '#06b6d4' },
+ creative: { from: '#fee2e2', to: '#ef4444' }
+};
+
+function escapeXml(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+}
+
+// Wrap business name across at most 2 lines, max ~22 chars per line.
+// SVG <text> doesn't auto-wrap, so we break in JS and emit two <tspan>s.
+function wrapName(name, maxPerLine = 22) {
+ const words = String(name || '').split(/\s+/).filter(Boolean);
+ if (words.length <= 1) return [name];
+ let line1 = '', line2 = '';
+ for (const w of words) {
+ if (!line1 || (line1 + ' ' + w).length <= maxPerLine) {
+ line1 = line1 ? line1 + ' ' + w : w;
+ } else {
+ line2 = line2 ? line2 + ' ' + w : w;
+ }
+ }
+ if (line2.length > maxPerLine) line2 = line2.slice(0, maxPerLine - 1) + '…';
+ return line2 ? [line1, line2] : [line1];
+}
+
+function buildSvg({ businessName, verticalKey, verticalLabel, neighborhood }) {
+ const grad = VERTICAL_GRADIENT[verticalKey] || { from: '#e5e7eb', to: '#6b7280' };
+ const nameLines = wrapName(businessName, 22);
+ const fontSize = nameLines.length === 2 ? 78 : 96;
+ const ny1 = nameLines.length === 2 ? 286 : 332;
+ const ny2 = ny1 + fontSize + 4;
+
+ const subtitleParts = [verticalLabel, neighborhood].filter(Boolean).join(' · ');
+
+ const tspans = nameLines.map((line, i) =>
+ `<tspan x="80" y="${i === 0 ? ny1 : ny2}">${escapeXml(line)}</tspan>`
+ ).join('');
+
+ return `<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
+ <defs>
+ <linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
+ <stop offset="0%" stop-color="${grad.from}"/>
+ <stop offset="100%" stop-color="${grad.to}"/>
+ </linearGradient>
+ </defs>
+ <rect width="1200" height="630" fill="url(#g)"/>
+ <rect x="60" y="60" width="1080" height="510" fill="rgba(255,255,255,0.06)" stroke="rgba(255,255,255,0.32)" stroke-width="2" rx="6"/>
+ <text x="80" y="160" fill="rgba(255,255,255,0.92)"
+ font-family="Cormorant Garamond, Georgia, serif" font-size="22"
+ letter-spacing="0.24em">VENTURA CLAW</text>
+ <text fill="#0e0e0e" font-family="Cormorant Garamond, Georgia, serif"
+ font-size="${fontSize}" font-weight="500">${tspans}</text>
+ <text x="80" y="500" fill="rgba(0,0,0,0.62)"
+ font-family="Inter, -apple-system, sans-serif" font-size="32"
+ letter-spacing="0.04em">${escapeXml(subtitleParts)}</text>
+ <text x="80" y="570" fill="rgba(255,255,255,0.78)"
+ font-family="Inter, -apple-system, sans-serif" font-size="20"
+ letter-spacing="0.16em">VENTURACLAW.COM</text>
+</svg>`;
+}
+
+async function renderPng(opts) {
+ if (!sharp) throw new Error('sharp_unavailable');
+ const svg = buildSvg(opts);
+ const png = await sharp(Buffer.from(svg, 'utf8'))
+ .png({ compressionLevel: 9 })
+ .toBuffer();
+ return png;
+}
+
+module.exports = { renderPng, buildSvg, VERTICAL_GRADIENT };
diff --git a/routes/public.js b/routes/public.js
index f2c951e..dd5ca3d 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -217,17 +217,55 @@ router.get('/business/:slug', async (req, res, next) => {
`, [biz.id, biz.vertical, biz.latitude, biz.longitude]);
}
+ // Open Graph image: prefer the business's own photo when present (better
+ // social-share preview), else fall back to the dynamically rendered
+ // /og.png card. Both are absolute URLs because OG crawlers don't run JS
+ // and need fully-qualified hrefs.
+ const _publicUrl = (process.env.PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
+ const ogImage = biz.photo_path
+ ? (biz.photo_path.startsWith('http') ? biz.photo_path : _publicUrl + biz.photo_path)
+ : `${_publicUrl}/business/${encodeURIComponent(biz.slug)}/og.png`;
+
res.render('public/business', {
title: `${biz.business_name} · ${BY_KEY[biz.vertical]?.label || biz.vertical} on Ventura Blvd`,
metaDescription: biz.headline || `${biz.business_name} — ${BY_KEY[biz.vertical]?.label || biz.vertical} in ${biz.neighborhood || biz.city || 'the Ventura Blvd corridor'}.`,
business: biz,
verticalMeta: BY_KEY[biz.vertical] || null,
activity, showActivity, nearby,
- verticals: VERTICALS
+ verticals: VERTICALS,
+ ogImage
});
} catch (err) { next(err); }
});
+// Dynamic OG image. 24h CDN cache. Generated PNG never carries personal
+// data — just business name, vertical, neighborhood — so caching is safe.
+router.get('/business/:slug/og.png', async (req, res, next) => {
+ try {
+ const biz = await db.one(`
+ SELECT slug, business_name, vertical, neighborhood
+ FROM businesses WHERE slug = $1 AND status = 'active'
+ `, [req.params.slug]);
+ if (!biz) return res.status(404).type('text/plain').send('not_found');
+
+ const og = require('../lib/og-image');
+ const png = await og.renderPng({
+ businessName: biz.business_name,
+ verticalKey: biz.vertical,
+ verticalLabel: BY_KEY[biz.vertical]?.label || biz.vertical,
+ neighborhood: biz.neighborhood || ''
+ });
+ res.set('Content-Type', 'image/png');
+ res.set('Cache-Control', 'public, max-age=86400, immutable');
+ res.send(png);
+ } catch (err) {
+ if (err && err.message === 'sharp_unavailable') {
+ return res.status(503).type('text/plain').send('og_unavailable');
+ }
+ next(err);
+ }
+});
+
router.post('/business/:slug/contact', async (req, res, next) => {
try {
const biz = await db.one(`SELECT id, slug, business_name, claim_status, status FROM businesses WHERE slug = $1`, [req.params.slug]);
diff --git a/views/public/business.ejs b/views/public/business.ejs
index 4ec2310..7a78159 100644
--- a/views/public/business.ejs
+++ b/views/public/business.ejs
@@ -1,8 +1,6 @@
<%- include('../partials/head', {
title,
- ogImage: business.photo_path
- ? ((typeof publicUrl !== 'undefined' && publicUrl ? publicUrl.replace(/\/+$/, '') : 'https://leads.venturaclaw.com') + business.photo_path)
- : null
+ ogImage: typeof ogImage !== 'undefined' && ogImage ? ogImage : null
}) %>
<%- include('../partials/header') %>
← c5e7571 feat(admin): surface share-count on dashboard stat row
·
back to Ventura Claw Leads
·
feat(seo): image sitemap support for Google Image search 92a37d6 →