DEV Community

Max Sveshnikov
Max Sveshnikov

Posted on

Simple SEO fix for Vite/React SPAs without switching to Next/Remix

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'));
Enter fullscreen mode Exit fullscreen mode

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;
    }
};
Enter fullscreen mode Exit fullscreen mode

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)