Make next event visible to AI crawlers (SSR, JSON-LD, meta, llms.txt)

- SSR next event on homepage; pass initialEvent from server to avoid client-only content
- Add schema.org Event JSON-LD on homepage when next event exists
- Dynamic homepage metadata (description, OG, Twitter) with next event date
- Add dynamic /llms.txt route for AI-friendly plain-text event info
- Revalidation: support next-event tag; backend revalidates sitemap + next-event on event CUD
- Allow /llms.txt in robots.txt

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-02-12 04:10:49 +00:00
parent af94c99fd2
commit 5885044369
7 changed files with 348 additions and 23 deletions

View File

@@ -9,17 +9,23 @@ import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
export default function NextEventSection() {
interface NextEventSectionProps {
initialEvent?: Event | null;
}
export default function NextEventSection({ initialEvent }: NextEventSectionProps) {
const { t, locale } = useLanguage();
const [nextEvent, setNextEvent] = useState<Event | null>(null);
const [loading, setLoading] = useState(true);
const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null);
const [loading, setLoading] = useState(!initialEvent);
useEffect(() => {
// Skip fetch if we already have server-provided data
if (initialEvent !== undefined) return;
eventsApi.getNextUpcoming()
.then(({ event }) => setNextEvent(event))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
}, [initialEvent]);
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {

View File

@@ -2,8 +2,13 @@
import { useLanguage } from '@/context/LanguageContext';
import NextEventSection from './NextEventSection';
import { Event } from '@/lib/api';
export default function NextEventSectionWrapper() {
interface NextEventSectionWrapperProps {
initialEvent?: Event | null;
}
export default function NextEventSectionWrapper({ initialEvent }: NextEventSectionWrapperProps) {
const { t } = useLanguage();
return (
@@ -13,7 +18,7 @@ export default function NextEventSectionWrapper() {
{t('home.nextEvent.title')}
</h2>
<div className="mt-12 max-w-3xl mx-auto">
<NextEventSection />
<NextEventSection initialEvent={initialEvent} />
</div>
</div>
</section>

View File

@@ -1,3 +1,4 @@
import type { Metadata } from 'next';
import HeroSection from './components/HeroSection';
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
import AboutSection from './components/AboutSection';
@@ -5,13 +6,157 @@ import MediaCarouselSection from './components/MediaCarouselSection';
import NewsletterSection from './components/NewsletterSection';
import { getCarouselImages } from '@/lib/carouselImages';
export default function HomePage() {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface NextEvent {
id: string;
title: string;
titleEs?: string;
description: string;
descriptionEs?: string;
shortDescription?: string;
shortDescriptionEs?: string;
startDatetime: string;
endDatetime?: string;
location: string;
locationUrl?: string;
price: number;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string;
externalBookingEnabled?: boolean;
externalBookingUrl?: string;
availableSeats?: number;
bookedCount?: number;
isFeatured?: boolean;
createdAt: string;
updatedAt: string;
}
async function getNextUpcomingEvent(): Promise<NextEvent | null> {
try {
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
next: { tags: ['next-event'] },
});
if (!response.ok) return null;
const data = await response.json();
return data.event || null;
} catch {
return null;
}
}
// Dynamic metadata with next event date for AI crawlers and SEO
export async function generateMetadata(): Promise<Metadata> {
const event = await getNextUpcomingEvent();
if (!event) {
return {
title: 'Spanglish Language Exchange Events in Asunción',
description:
'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
};
}
const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const description = `Next event: ${eventDate} ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`;
return {
title: 'Spanglish Language Exchange Events in Asunción',
description,
openGraph: {
title: 'Spanglish Language Exchange Events in Asunción',
description,
url: siteUrl,
siteName: 'Spanglish',
type: 'website',
images: [
{
url: event.bannerUrl
? event.bannerUrl.startsWith('http')
? event.bannerUrl
: `${siteUrl}${event.bannerUrl}`
: `${siteUrl}/images/og-image.jpg`,
width: 1200,
height: 630,
alt: `Spanglish ${event.title}`,
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Spanglish Language Exchange Events in Asunción',
description,
},
};
}
function generateNextEventJsonLd(event: NextEvent) {
return {
'@context': 'https://schema.org',
'@type': 'Event',
name: event.title,
description: event.shortDescription || event.description,
startDate: event.startDatetime,
endDate: event.endDatetime || event.startDatetime,
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
eventStatus:
event.status === 'cancelled'
? 'https://schema.org/EventCancelled'
: 'https://schema.org/EventScheduled',
location: {
'@type': 'Place',
name: event.location,
address: {
'@type': 'PostalAddress',
addressLocality: 'Asunción',
addressCountry: 'PY',
},
},
organizer: {
'@type': 'Organization',
name: 'Spanglish',
url: siteUrl,
},
offers: {
'@type': 'Offer',
price: event.price,
priceCurrency: event.currency,
availability:
(event.availableSeats ?? 0) > 0
? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.id}`,
},
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.id}`,
};
}
export default async function HomePage() {
const carouselImages = getCarouselImages();
const nextEvent = await getNextUpcomingEvent();
return (
<>
{nextEvent && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(generateNextEventJsonLd(nextEvent)),
}}
/>
)}
<HeroSection />
<NextEventSectionWrapper />
<NextEventSectionWrapper initialEvent={nextEvent} />
<AboutSection />
<MediaCarouselSection images={carouselImages} />
<NewsletterSection />