Tech 5 min read

A Simple OGP Solution for SPAs and Dynamically Generated Pages

At a client job, I built a site using an HTML template with JS-driven dynamic content injection — no React or Vue — specifically so the client could edit it easily later.

The server environment was a shared hosting plan with PHP available. No Node.js, no CI/CD pipeline, so SSR and SSG were off the table. PHP was usable though.

Building the site itself wasn’t particularly hard, but one problem came up:

“This means article pages won’t have OGP.”

OGP crawlers don’t wait for dynamic content to render. Whatever static OGP you put in the HTML is all they’ll see.

Here’s the thought process and solution I landed on.

The Problem: Dynamic Generation and OGP Don’t Mix

OGP crawlers (Twitterbot, Facebook, LINE, Slack, etc.) generally don’t execute JavaScript. They just fetch the page’s HTML and read the meta tags in <head>.

That means page titles and descriptions generated dynamically by JS never make it into OGP.

The result: no matter which article you share, only the site-wide default OGP appears.

Approaches I Considered and Rejected

1. Detecting Crawlers via User-Agent in .htaccess

My first idea was to detect crawler User-Agents in .htaccess and redirect them to crawler-specific pages.

RewriteCond %{HTTP_USER_AGENT} (Twitterbot|facebookexternalhit|Slackbot) [NC]
RewriteRule ^articles/(.*)$ /ogp-pages/$1.html [L]

Rejected: Maintaining a crawler list isn’t realistic.

There are effectively unlimited crawlers out there. Even just the major ones are numerous, and new services keep appearing. Some public crawler lists exist:

Domestic Japanese lists are too sparse, so you’d rely on foreign sources — and keeping those up to date indefinitely just isn’t practical.

2. OGP Injection on First Visit via History API

Here’s the flow I considered:

  1. .htaccess appends something like ?init=1 on first access
  2. All first-time requests go to PHP, which returns the page with OGP
  3. Redirect to the article page
  4. Use the History API to remove the redirect from browser history so the back button doesn’t land on the OGP page
  5. Also strip the query from the URL while at it

Rejected: Too many moving parts to manage.

First-visit detection, redirecting, history manipulation, query removal — each step is a debugging nightmare if anything breaks. If it’s this complicated, a different approach is worth considering.

3. PHP Embedded in HTML Template

Embed PHP into the .html template files, detect the URL, and output the OGP from PHP.

Rejected: Hard to explain at handoff.

  • Requires server configuration to execute PHP in .html files
  • Awkward to tell the client “yes there’s PHP inside the HTML”
  • The whole point of using raw HTML + JS was so the client could edit it like a plain HTML file
  • Won’t be usable if they later decide to switch to Vue

The Chosen Approach: Route All Requests Through PHP for OGP Injection

The solution was to route all requests to article pages through an OGP-processing PHP script.

Initially I was thinking about outputting OGP first and then redirecting, but I was constrained by the assumption that the client would be editing the HTML directly.

Once I stepped back and thought clearly: just modify the HTML output before it’s sent. As long as the <head> content isn’t doing anything weird, this is fine.

It might sound like option 1, but it’s different:

  • Option 1: detect crawlers via UA and route only crawlers elsewhere → maintaining the crawler list is a nightmare
  • This approach: route all requests through PHP → no UA management needed

The Logic

  1. .htaccess rewrites all article page requests to PHP
  2. PHP fetches article data from an API and generates OGP tags
  3. PHP reads the original HTML template, injects OGP into <head>, and returns it
  4. JS dynamic content still runs normally (for human visitors)

No need to detect whether the visitor is a crawler. Same processing for everyone.

Implementation

RewriteEngine On

# Route article pages through PHP
RewriteRule ^news/([0-9]+)/?$ ogp_injector.php?target=news&id=$1 [L]
RewriteRule ^works/([0-9]+)/?$ ogp_injector.php?target=works&id=$1 [L]
<?php
$id = $_GET['id'] ?? null;
$target = $_GET['target'] ?? null;

// Configuration based on target
$templateFile = '';
$apiUrl = '';

if ($target === 'news') {
    $templateFile = 'news_single.html';
    $apiUrl = 'https://api.example.com/news/' . $id;
} elseif ($target === 'works') {
    $templateFile = 'works_single.html';
    $apiUrl = 'https://api.example.com/works/' . $id;
} else {
    header('Location: /');
    exit;
}

// Fetch article data from API
$ogTags = '';
if ($id && $apiUrl) {
    $response = file_get_contents($apiUrl);
    if ($response) {
        $data = json_decode($response, true);
        
        $title = htmlspecialchars($data['title'] ?? 'Title', ENT_QUOTES, 'UTF-8');
        $description = htmlspecialchars($data['description'] ?? '', ENT_QUOTES, 'UTF-8');
        $image = htmlspecialchars($data['thumbnail'] ?? '', ENT_QUOTES, 'UTF-8');
        $url = 'https://example.com/' . $target . '/' . $id;
        
        $ogTags = <<<OG
        <meta property="og:title" content="{$title}">
        <meta property="og:description" content="{$description}">
        <meta property="og:image" content="{$image}">
        <meta property="og:url" content="{$url}">
        <meta property="og:type" content="article">
        <meta name="twitter:card" content="summary_large_image">
        OG;
    }
}

// Load template and inject OGP
if ($templateFile && file_exists($templateFile)) {
    $html = file_get_contents($templateFile);
    
    if ($ogTags) {
        $html = str_replace('<head>', "<head>\n" . $ogTags, $html);
    }
    
    echo $html;
} else {
    header('Location: /');
    exit;
}

Why This Works Well

No UA Management Needed

No crawler detection. The same process runs for all requests, so new crawlers are handled automatically.

HTML Template Stays Clean

The HTML files you hand off are pure HTML. PHP lives separately as “the delivery layer,” making it easy to explain to the client.

Reusable When Migrating Frameworks

If the client later decides to rebuild with Vue, the PHP OGP injection layer can stay in place as long as they use static output (SSG). It’s independent of the frontend implementation.

Note: if they switch to SSR, PHP-level injection won’t work. In that case, use the framework’s meta tag management (like useHead in Nuxt).

For SPA mode, this approach still works if you can inject into the <head> of index.html. Just don’t remove the framework’s OGP output (useHead, etc.) — direct links go through PHP, but in-app navigation is handled by JS, so meta tags like title still need to update.


Hope this helps anyone struggling with OGP on SPAs or dynamically generated sites.