Social sharing and favicons
The one thing that matters
og:image and og:url must be absolute URLs on a publicly reachable host.
The crawler fetches them from its own servers. It has no page context, no cookies, no
referer, and no idea what your relative path is relative to. A path like
/assets/og.jpg or assets/og.jpg resolves to nothing and the card comes back blank.
<!-- dead: renders an empty card everywhere -->
<meta property="og:image" content="assets/images/og-image.jpg">
<!-- works -->
<meta property="og:image" content="https://example.com/assets/images/og-image.jpg">
This is the single most common reason a preview does not work. Before debugging anything else, confirm the URL is absolute and loads in a private window.
The corollary: previews cannot work on localhost or file://. Nothing you do locally
will produce a real preview. Deploy first, then validate.
The tag set
<link rel="canonical" href="https://example.com/">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Brand">
<meta property="og:locale" content="en_US">
<meta property="og:url" content="https://example.com/">
<meta property="og:title" content="Brand - what it is">
<meta property="og:description" content="One sentence. Around 110 characters.">
<meta property="og:image" content="https://example.com/og-image.jpg">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="What is in the picture.">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Brand - what it is">
<meta name="twitter:description" content="One sentence. Around 110 characters.">
<meta name="twitter:image" content="https://example.com/og-image.jpg">
<meta name="twitter:image:alt" content="What is in the picture.">
Three details that are easy to get wrong:
og: uses property=, twitter: uses name=. Mixing them up is silently
ignored, so the tags look present in the source and do nothing. Some parsers tolerate
name= on og:, but do not rely on it.
twitter:card must be summary_large_image. The default is a small square card
with the image as a thumbnail beside the text, which wastes a 1200x630 image.
Declaring og:image:width and og:image:height makes the card render immediately.
Without them the platform has to download the image before it knows the aspect, so the
first person to share the link often sees a text-only card. They must match the real
file. Measure it, do not assume.
Copy length: titles are cut around 60 characters, descriptions around 110 to 200 depending on platform. Front-load the meaning.
The share image
1200x630 (1.905:1). JPEG unless the design needs alpha, which it does not, since every platform composites onto white. Keep it under about 1 MB; the hard caps are 8 MB (Facebook) and 5 MB (X), but large files time out during scraping.
Render it at the target ratio, do not crop a screenshot
Taking an arbitrary screenshot and cropping to 1.905:1 throws away the composition the designer chose. Render the source at the ratio instead:
# 1440x756 is exactly 1.905:1, so it downscales to 1200x630 with no crop
chrome --headless --disable-gpu --hide-scrollbars --virtual-time-budget=8000 \
--window-size=1440,756 --screenshot=og-src.png "file://$PWD/index.html"
The short-viewport trap
If the page has a full-height hero (100vh / 100svh), rendering directly at 1.905:1
gives a viewport far shorter than a real desktop screen. Any object-fit: cover media
crops much harder than it does in real use, and elements that normally clear each other
collide - typically a header logo landing on the subject's head.
Render at a taller viewport so the media crops the way it does in real use, then cut the 1.905:1 window out of it:
chrome --headless ... --window-size=1440,1000 --screenshot=og-src.png "file://$PWD/index.html"
ffmpeg -i og-src.png -vf "crop=1440:756:0:0" -q:v 2 og-crop.png # top-anchored
Compare a top-anchored crop against a centred one before choosing. Top-anchored usually keeps the brand furniture (utility bar, nav, wordmark); centred usually keeps the subject and the call to action. You cannot have both, because a full-height hero is taller than 1.905:1 allows.
Framing
Cards render around 500 px wide in a feed, and each platform crops differently (LinkedIn and Slack are tighter than Facebook). Keep anything that must survive inside the central 80%, and set any type large enough to read at half size. Fine UI detail disappears; a big wordmark and a strong image do not.
Favicons
The font trap
Do not put <text> in an SVG favicon.
<!-- breaks: renders in whatever fallback font the viewer happens to have -->
<svg ...><text x="50%" y="50%" font-family="Geist">G</text></svg>
The SVG renders on the viewer's machine, where your webfont does not exist. Extract the glyph as a path so it is font-independent and resolution-independent. See the script below.
The transparency trap
A dark glyph on a transparent background vanishes against dark browser chrome, and a light one vanishes on light. Draw the icon on a filled tile. It also gives the mark a consistent silhouette across tab bars, bookmarks and home screens.
The file set
| File | Size | Why |
|---|---|---|
/favicon.ico | 16, 32, 48 | browsers request this path implicitly even when you declare others; keep it at the web root |
favicon.svg | scalable | what modern browsers prefer, and it stays sharp on any display |
apple-touch-icon.png | 180 | iOS home screen |
icon-192.png | 192 | manifest |
icon-512.png | 512 | manifest, splash screens |
icon-maskable-512.png | 512 | Android adaptive masks |
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="/assets/icons/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/assets/icons/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
The maskable safe zone
Android clips icons to arbitrary shapes (circle, squircle, teardrop). A maskable icon
must keep everything meaningful inside the central 80% circle, which means roughly
40% of the tile edge is disposable padding. Reuse the normal icon and it will get its
edges shaved off.
In practice: if the glyph covers 60% of the tile in the standard icon, drop it to about
40% for the maskable one. Ship both, with "purpose": "any" and "purpose": "maskable"
as separate entries. Never put both purposes on one file.
Web manifest
{
"name": "Brand",
"short_name": "Brand",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f7f6f4",
"theme_color": "#0a0a0a",
"icons": [
{ "src": "/assets/icons/favicon.svg", "type": "image/svg+xml", "sizes": "any" },
{ "src": "/assets/icons/icon-192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
{ "src": "/assets/icons/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" },
{ "src": "/assets/icons/icon-maskable-512.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
]
}
theme_color tints mobile browser chrome, so it should match whatever sits at the
very top of the page, not the brand's main colour. If the page opens on a black
utility bar, use that black. A mismatch reads as a rendering bug. Mirror it in
<meta name="theme-color">.
background_color is the splash screen behind the icon before first paint, so it should
match the page background.
Generation script
Needs pillow and fonttools. Produces the whole icon set plus the share image.
import os
from PIL import Image, ImageDraw, ImageFont
from fontTools.ttLib import TTFont
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.boundsPen import BoundsPen
FONT = "brand.ttf" # the real brand face, at the weight the wordmark uses
CHAR = "G"
INK = "#0a0a0a" # tile
PAPER = "#f2f1ed" # glyph
COVER = 0.60 # glyph height as a share of the tile
OUT = "assets/icons"
os.makedirs(OUT, exist_ok=True)
# ── SVG: glyph as a real path, so it does not depend on the viewer's fonts ──
f = TTFont(FONT)
gs = f.getGlyphSet()
name = f.getBestCmap()[ord(CHAR)]
bp = BoundsPen(gs); gs[name].draw(bp)
x0, y0, x1, y1 = bp.bounds
pen = SVGPathPen(gs); gs[name].draw(pen)
d = pen.getCommands()
VB = 64
s = (VB * COVER) / (y1 - y0)
tx = (VB - (x1 - x0) * s) / 2 - x0 * s
ty = (VB + (y1 - y0) * s) / 2 + y0 * s # y flips: fonts are y-up, SVG is y-down
open(f"{OUT}/favicon.svg", "w").write(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {VB} {VB}">'
f'<rect width="{VB}" height="{VB}" fill="{INK}"/>'
f'<path transform="translate({tx:.3f} {ty:.3f}) scale({s:.5f} -{s:.5f})" '
f'd="{d}" fill="{PAPER}"/></svg>')
# ── Raster tiles ────────────────────────────────────────────────────────────
def tile(px, cover=COVER):
img = Image.new("RGB", (px, px), INK)
dr = ImageDraw.Draw(img)
fnt = ImageFont.truetype(FONT, px * 2)
bb = dr.textbbox((0, 0), CHAR, font=fnt)
fnt = ImageFont.truetype(FONT, int(px * 2 * (px * cover) / (bb[3] - bb[1])))
bb = dr.textbbox((0, 0), CHAR, font=fnt)
dr.text(((px - (bb[2] - bb[0])) / 2 - bb[0],
(px - (bb[3] - bb[1])) / 2 - bb[1]), CHAR, font=fnt, fill=PAPER)
return img
tile(180).save(f"{OUT}/apple-touch-icon.png", optimize=True)
tile(192).save(f"{OUT}/icon-192.png", optimize=True)
tile(512).save(f"{OUT}/icon-512.png", optimize=True)
tile(512, cover=0.42).save(f"{OUT}/icon-maskable-512.png", optimize=True) # 40% safe zone
tile(256).save("favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)])
# ── Share image: crop a taller render to ratio, then downscale ──────────────
src = Image.open("og-src.png").convert("RGB") # e.g. 1440x1000
src.crop((0, 0, 1440, 756)).resize((1200, 630), Image.LANCZOS).save(
"assets/images/og-image.jpg", quality=88, optimize=True, progressive=True)
No brand font available? Any geometric grotesque works for a monogram. Rasterising an
existing logo PNG onto the tile is fine too; just centre it inside the same COVER
fraction and keep the maskable variant smaller.
Verify before shipping
import re, json, os
from PIL import Image
s = open("index.html").read()
tags = dict(re.findall(r'(?:property|name)="((?:og|twitter):[^"]+)"\s+content="([^"]+)"', s))
for t in ("og:url", "og:image", "twitter:image"):
assert tags[t].startswith("http"), f"{t} is not absolute: {tags[t]}"
w, h = Image.open("assets/images/og-image.jpg").size
assert (str(w), str(h)) == (tags["og:image:width"], tags["og:image:height"]), "declared size is wrong"
assert tags["twitter:card"] == "summary_large_image"
m = json.load(open("site.webmanifest"))
for i in m["icons"]:
assert os.path.isfile(i["src"].lstrip("/")), f"missing icon {i['src']}"
print("ok")
Checklist:
-
og:imageandog:urlabsolute, on the real host, loading in a private window - declared
og:image:width/heightmatch the actual file -
twitter:cardissummary_large_image -
og:tags useproperty=,twitter:tags usename= - every placeholder domain replaced (grep for it; there are usually four)
-
favicon.icoat the web root, not only in an assets folder - SVG favicon contains a
<path>, no<text> - icon tile is filled, so it survives dark and light browser chrome
- maskable icon padded to the central 80%
-
theme_colormatches the top edge of the page
Caching
Every platform caches aggressively and will keep serving the old card after you fix it. Re-scrape:
- Facebook, Instagram, WhatsApp: https://developers.facebook.com/tools/debug/
- X: https://cards-dev.twitter.com/validator
- LinkedIn: https://www.linkedin.com/post-inspector/
LinkedIn caches hardest and effectively ignores changes for around 7 days unless you run
the inspector. If a URL has already been shared widely with a broken card, adding a
query string (?v=2) forces a fresh scrape.
Favicons cache hard in the browser too. Test in a private window; a normal reload will usually keep serving the old one.
Placeholder domains
When the real domain is not known yet, use a subdomain of example.com
(https://brand.example.com). RFC 2606 reserves it, so it is unmistakably a placeholder
and can never accidentally point at a domain somebody else owns. Never leave a plausible
but unowned domain in shipped metadata.
Put the replacement instruction in a comment directly above the block and in the
project README, and say how many occurrences there are. It is normally four: canonical,
og:url, og:image, twitter:image.