OpenGraph and JSON-LD for Ionic apps
One of the primary features of TopVault is to be a library of collectibles. This means indexing all variants and maintaining descriptions, historic pricing information, and links to other resources related to special rare collectibles. This experience is optimal if each collectible is indexable by search engines and shows up nicely when shared on social media.
The technologies that make this work are a combination of HTML header metadata, OpenGraph tags, and JSON-LD schema. Unfortunately SPAs like Ionic deployments by nature are static content hydrated by Javascript. Services that use the share data will GET the content and parse without rendering or supporting client-side execution. So the data must be present with a standard curl request for the page shared or indexed.
Through researching strategies to support these, two options emerged:
Rewrite/refactor the Ionic implementation to be rendered server-side.
Use functions or workers to intercept responses and inject the content.
The second approach is sound, and allows TopVault to continue to be development with Ionic. This article covers the TopVault experience of using Firebase functions as a solution.
Injecting metadata into Ionic
The first step is to prepare the index.html served by the Ionic app:
<!doctype html>
<html lang="en" mode="ios">
<head>
<!-- Place app-specific metadata fields here -->
<title>TopVault | Track and score your collecting journey</title>
<meta name="description" content="Track and score your collecting journey" />
<!-- Have default placeholders for OpenGraph properties -->
<meta property="og:title" content="TopVault | App" />
<meta property="og:url" content="https://vault.top/app" />
<meta property="og:image" content="https://vault.top/images/logo-og.webp" />
<meta property="og:description" content="Track and score your collecting journey" />
<meta property="og:type" content="website" />
<meta property="og:logo" content="https://vault.top/images/logo.webp" />
<meta property="og:site_name" content="TopVault" />
<!-- Add an empty default JSON-LD entry -->
<script type="application/ld+json" id="jsonld-slot">{}</script>
</head>
<!-- snip... -->
</html>
For TopVault there is a secondary script tag for JSON-LD that hosts the static graph information for the website, organization, and app. That second element is not shown here because the content is not replaced.
Firebase hosting redirects
I named the Firebase function meta since the main purpose is updating metadata for each request. In the Firebase hosting configuration for the application, redirects connect the path to the function.
Example from the firebase.json configuration:
{
"hosting": [{
"target": "app",
// [...snip...]
"rewrites": [
{ "source": "/app/browse/*", "function": "meta" },
{ "source": "/app/browse/*/info/item/*", "function": "meta" },
{ "source": "/app/browse/*/info/series/*", "function": "meta" },
{ "source": "/app/browse/*/info/series", "function": "meta" },
{ "source": "/app/collection/*/entry/item/*", "function": "meta" }
// and so on...
]
}]
}
Now for each entry in the rewrites, the meta Firebase function will be called.
Firebase function
The TopVault app uses a mono-repo for the backend services, static website, Ionic app, and now Firebase function definition.
The function configuration for firebase:
{
// [...snip...]
"functions": [{
"source": "/dir/where/function/defined",
"codebase": "default",
"ignore": [ "node_modules", ".git", "firebase-debug*", "*.local" ],
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run build"
]
}]
}
The logic of the function first requests the SPA content, as if no redirect exists. Then it detects the redirected endpoint and requests the appropriate metadata from TopVault's API server.
async function fetchSpaHtml(): Promise<string> {
const spa = 'https://vault.top/app';
const response = await fetch(spa);
return response.text();
}
export const meta = https.onRequest(async (req, res) => {
// Request the default-redirect HTML content.
let html = await fetchSpaHtml();
const requestPath = req.path.startsWith('/app') ? req.path.substring(4) : req.path;
const pathParts = requestPath.split('/');
// Attempt to match against any of the open-graph share types.
let details: OpenGraphDetails | null = null;
if (isInfoShare(pathParts)) {
const collectionType = pathParts[2];
const infoItemId = pathParts[5];
details = await fetchItemDetails(collectionType, infoItemId);
} else if (isSeriesShare(pathParts)) {
const collectionType = pathParts[2];
const seriesId = pathParts[5];
details = await fetchSeriesDetails(collectionType, seriesId);
}
// ...snip...
if (details != null) {
html = updateMetaTags(html, `https://vault.top/app${requestPath}`, details as CombinedDetails);
}
res.set('Cache-Control', 'public, max-age=3600');
res.status(200).send(html);
}
And the updateMetaTags implementation will perform the find-and-replace logic. It is important that the content inject is never user-controlled. This type of basic HTML parsing will lead to vulnerabilities if user content is included.
function updateMetaTags(html: string, url: string, details: CombinedDetails): string {
let out = html;
// Replace SEO tags.
out = out.replace(/<title>.*?<\/title>/, `<title>TopVault | ${details.title}</title>`);
if (details.description.length > 0) {
out = out.replace(/<meta name="description" content="[^"]*" \/>/, `<meta name="description" content="${details.description}" />`);
}
// Replace OpenGraph tags.
out = out.replace(
/<meta property="og:title" content="[^"]*" \/>/,
`<meta property="og:title" content="TopVault | ${details.title}" />`
);
out = out.replace(/<meta property="og:image" content="[^"]*" \/>/, `<meta property="og:image" content="${details.image}" />`);
out = out.replace(/<meta property="og:url" content="[^"]*" \/>/, `<meta property="og:url" content="${url}" />`);
out = out.replace(/<meta property="og:type" content="[^"]*" \/>/, `<meta property="og:type" content="object" />`);
if (details.description.length > 0) {
out = out.replace(
/<meta property="og:description" content="[^"]*" \/>/,
`<meta property="og:description" content="${details.description}" />`
);
}
// Handle optional JSON-LD content if provided.
// ...snip...
Each of the fetch* functions are simple wrappers around making API calls to a backend service.
Putting this all together the share URL for a Mega Evolution Bulbasaur:
https://vault.top/app/browse/pokemon-card/info/item/c041805a-396b-4b78-9b31-f2f51c88279e
Will show up properly in text message clients, Facebook shares, Twitter/X shares, and so forth.
TopVault: Tech Blog