I run a one-person French AI voiceover shop. The storefront is a static site on Netlify, the "marketing team" is me, and the budget is $0. This week the site got its second SEO layer: six landing pages, one per search intent, all generated by a single Python script and deployed in one command.
This is the field report — including the bug I shipped to production and caught only by looking at the page.
Why six pages instead of one
My homepage already ranked for nothing, because it tried to rank for everything: "voice over", "publicité", "audiobook", "IVR", "avatar" all on one URL. One page can't match six different search intents. Somebody typing "message d'accueil téléphonique professionnel" wants a phone-answering message, not a homepage that also sells audiobook narration.
So the rule I followed: one page = one intent = one keyword cluster.
-
voix-off-publicite.html— 30-second ad spots -
voix-off-audiobook.html— audiobook narration -
voix-off-ivr.html— phone system / IVR messages -
avatar-publicite.html— AI avatar ad videos -
avatar-temoignage.html— AI avatar testimonial videos -
avatar-formation.html— AI avatar training videos
Each page gets its own title, meta description, H1, copy, price and call-to-action. No spun text, no find-and-replace city names — each page's copy is written for its own use case, because duplicate content is how you get six pages that all rank nowhere instead of one that ranks somewhere.
The generator: template + a list of dicts
The whole thing is one Python file. A page() function renders the HTML shell (head, OG tags, JSON-LD, inline CSS), and a PAGES list holds one dict per page with the unique content:
PAGES = [
dict(slug="voix-off-publicite.html",
title="Voix Off Publicité Française — Spot 30s dès $0.05 | IA Neuronale 24h",
desc="Voix off française pour publicité radio, web et réseaux sociaux. ...",
h1="Voix off publicité française — spot 30 s livré en 24 h",
price="0.05",
body_html="""<section><h2>Ce que vous recevez</h2>...""",
jsonld_name="Voix off publicité française 30 s",
...),
# ... 5 more
]
for p in PAGES:
html = page(**p)
open(os.path.join(OUT, p["slug"]), "w").write(html)
Two details earned their keep immediately.
1. The script verifies its own asset references. Each page embeds demo MP3s/MP4s that already existed on the site. After writing the files, the script regex-scans every src= it generated and checks the file exists on disk:
for m in re.findall(r'(?:src|href)="(audio/[^"]+|video/[^"]+)"', all_html):
if not os.path.exists(os.path.join(OUT, m)):
missing.add(m)
print("MANQUANTS:", sorted(missing) if missing else "aucun")
A typo'd filename becomes a build error, not a silent dead player on a live page.
2. Structured data is per-page, not global. Every page emits its own Product JSON-LD with its own name, description and price:
{"@context":"https://schema.org","@type":"Product",
"name":"Message IVR accueil téléphonique français",
"offers":{"@type":"Offer","price":"0.03","priceCurrency":"USD",
"availability":"https://schema.org/InStock"}}
Rich results are per-URL. A global sitewide schema would describe nothing precisely.
Deploy: one zip, one POST
Netlify takes a zip deploy over plain HTTP, so the deploy script is 30 lines of bash: copy the folder, rewrite the old domain to the new one with sed, zip, POST to api.netlify.com/api/v1/sites/$SITE_ID/deploys, then curl the live URLs and print the status codes. No CLI, no build minutes, no git integration. Total deploy time: about 20 seconds for the whole site.
Two Netlify behaviors surprised me:
-
It rewrites your internal links. I wrote
href="voix-off-publicite.html"; the served HTML containshref='/voix-off-publicite'with single quotes. Netlify's post-processing converts to pretty URLs. My first post-deploygrepcheck "failed" and I almost rolled back a working deploy — the links were there, just rewritten. Check with the pattern Netlify emits, not the pattern you wrote. -
Sitemap + robots are just files in the zip.
sitemap.xmlwith all 11 URLs and a two-linerobots.txtpointing at it ship with the site. No config panel.
Orphan pages don't rank: the internal mesh
Six fresh pages linked from nowhere are six orphans. Crawlers can find them via the sitemap, but internal links are what tells Google they matter. So after generating the pages I edited the two existing pages: each product section on the homepage now ends with a "learn more" link to its landing page (descriptive anchor text, not "click here"), and the avatar showcase page links the three avatar landings. One sed-able edit per section, redeploy, done.
The bug I shipped and only caught by looking
After the first deploy I ran the responsible checks: all six URLs returned 200, JSON-LD present, canonicals correct, audio players on the three voice pages. Green across the board. Ship it.
Then I actually opened a page — and the three avatar pages had no video on them. A landing page selling avatar videos with zero video demo. My generator had a demo_html field I'd left empty everywhere, and for the voice pages the demos were inside body_html, so they looked fine. HTTP 200 tells you the page exists. It says nothing about whether the page is any good.
Fix: three <video> tags, regenerate, redeploy, and this time verify by screenshot, not just by status code. The final QA pass for all six pages: HTTP 200 ×6, one screenshot, demo media playing (durations render in the players — proof the files load, not just the tags).
What it cost
- Generator: ~150 lines of Python, one evening
- Deploy: $0 (Netlify free tier, zip deploy API)
- Domain: $0 for now (
*.netlify.appsubdomain — a real domain is the next dollar the shop earns) - New backlinks: this article, plus the internal mesh
If you're running a tiny service business on a static site: one page per intent, generate them from one script, verify assets at build time, link them from your existing pages, and look at the rendered page before you call it done. The 200 is necessary. It is not sufficient.
The six pages live at voixoff-fr.netlify.app — e.g. /voix-off-publicite and /avatar-formation. The site is French, the voiceovers are French, the demos play right in the page. Feedback welcome in the comments.
Top comments (0)