Hey devs! Found a neat solution for SPA SEO issues that I wanted to share.
The Problem: I was using this basic Express setup to serve my Vite SPA:
app.get('*', (req, res) => {
res.sendFile(join(__dirname, '../dist/index.html'));
Initially thought about migrating to Next.js or Remix, but it seemed like overkill for my needs.
The Solution: Instead, I wrote this small middleware that dynamically injects meta tags based on the route. Here's the code:
export const enrichMetadata = async (html, slug) => {
try {
if (!slug) return html;
let itinerary =
(await Itinerary.findOne({ slug: slug })) || (await Itinerary.findById(slug));
if (!itinerary) return html;
const firstDayActivities = itinerary.itinerary?.[0]?.activities || [];
const firstDayHighlights = firstDayActivities
.slice(0, 2)
.map((act) => act.activity)
.join(' and ');
const highlightsText = firstDayHighlights ? ` Starting with ${firstDayHighlights}.` : '';
const itineraryContent = itinerary.itinerary
.map(
(day, i) =>
`Day ${i + 1}: ${day.activities.map((a) => '<h1>' + a.activity + '</h1>' + '<p>' + a.description + '</p>').join(', ')}`
)
.join('. ');
const $ = load(html);
$('title').text(`${itinerary.destination} Travel Guide - MyTrip.city`);
$('meta[name="description"]').attr(
'content',
`Discover ${itinerary.destination} with our GPS audio travel guide. ${itinerary.duration}-day itinerary with best attractions, activities, and local insights.${highlightsText}`
);
$('meta[property="og:title"]').attr('content', `${itinerary.destination} Travel Guide`);
$('meta[property="og:description"]').attr(
'content',
`Explore ${itinerary.destination} with our personalized travel itinerary.${highlightsText}`
);
$('meta[property="og:url"]').attr('content', 'https://mytrip.city/itinerary/' + slug);
if (itinerary.images?.[0]) {
$('meta[property="og:image"]').attr('content', itinerary.images[0]);
}
const schema = {
'@context': 'https://schema.org',
'@type': 'TouristTrip',
name: `${itinerary.destination} Travel Guide`,
description: `${itinerary.duration}-day itinerary in ${itinerary.destination}`,
itinerary: {
'@type': 'ItemList',
numberOfItems: itinerary.itinerary.length,
itemListElement: itinerary.itinerary.flatMap((day, index) =>
day.activities.map((activity, actIndex) => ({
'@type': 'ListItem',
position: index * day.activities.length + actIndex + 1,
item: {
'@type': 'TouristAttraction',
name: activity.activity,
description: activity.description
}
}))
)
},
touristType: ['Sightseeing', 'Cultural'],
estimatedDuration: `P${itinerary.duration}D`,
location: {
'@type': 'City',
name: itinerary.destination
}
};
$('head').append(`<script type="application/ld+json">${JSON.stringify(schema)}</script>`);
$('body').append(`<div style="display:none">${itineraryContent}</div>`);
return $.html();
} catch {
return html;
}
};
Why I think this is cool:
- Took literally 1 minute to implement
- Avoided the complexity of Next.js setup
- Gets the job done for SEO and social sharing
- Performance is still great
- Works with any SPA framework
Anyone else tried something similar? Wondering if there are any gotchas I should watch out for.
EDIT: Using cheerio for HTML parsing, forgot to mention that!
Top comments (0)