feat(www): load _events mdx files on /events listing (#45176)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature

## What is the current behavior?

The /events page only loads events from the Notion "Developer Events"
database and the Luma Community API. MDX files under `apps/www/_events/`
(including webinars like agency-webinar, sentry, datadog, figma-make)
are not surfaced on the listing, and past events could still appear
until the moment they ended because the filter compared against `now()`
rather than the current day.

Addresses
[DEBR-85](https://linear.app/supabase/issue/DEBR-85/events-page-powered-by-notion-page).

## What is the new behavior?

- New `getMdxEvents()` reads `apps/www/_events/*.mdx`, parses
frontmatter with `gray-matter`, and returns today-and-future events as
`SupabaseEvent`s.
- `/events` now merges Notion + mdx + Luma events.
- Past events are hidden across all sources by comparing against the
start of today (UTC) instead of `now()`, so events running today stay
visible throughout the day.

## Additional context

Links on mdx events point at the main_cta URL when it's an external
\`http(s)\` URL, otherwise fall back to the built \`/events/{slug}\`
page so on-demand recordings remain reachable.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Events can be sourced from MDX files and merged into site event
listings.
* Luma supports multiple calendars (community and hackathon) for richer
feeds.

* **Improvements**
  * Events now exclude anything before the start of the current UTC day.
  * Added a “Community Event” category filter and included it in counts.
  * Event title typography adjusted for improved readability.
* “Hosted by” text now only shows when hosts exist; host fallbacks
standardized.

* **Chores**
  * Build env updated to include hackathon API key.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Alan Daniel authored and GitHub committed 2026-05-08 11:22:35 -04:00
1 parent e46ee776b4
commit 73286972fb
10 files changed
+257 -95

No files matched your search

+66 -57
View File
@@ -1,6 +1,6 @@
import * as Sentry from '@sentry/nextjs'
import { NextRequest, NextResponse } from 'next/server'
import { DEFAULT_META_DESCRIPTION } from '~/lib/constants'
import { NextRequest, NextResponse } from 'next/server'
export interface LumaGeoAddressJson {
city: string
@@ -58,81 +58,90 @@ interface LumaResponse {
next_cursor?: string
}
export type LumaCalendar = 'community' | 'hackathon'
async function fetchLumaCalendar(
apiKey: string,
calendar: LumaCalendar,
after: string | null,
before: string | null
) {
const lumaUrl = new URL('https://public-api.lu.ma/public/v1/calendar/list-events')
if (after) lumaUrl.searchParams.append('after', after)
if (before) lumaUrl.searchParams.append('before', before)
const response = await fetch(lumaUrl.toString(), {
method: 'GET',
headers: {
accept: 'application/json',
'x-luma-api-key': apiKey,
},
})
if (!response.ok) {
throw new Error(`Luma API error (${calendar}): ${response.status} ${response.statusText}`)
}
const data: LumaResponse = await response.json()
return data.entries
.filter(({ event }) => event.visibility === 'public')
.map(({ event }) => ({
id: event.api_id,
calendar,
start_at: event.start_at,
end_at: event.end_at,
name: event.name,
city: event.geo_address_json?.city,
country: event.geo_address_json?.country,
url: event.url,
timezone: event.timezone,
cover_url: event.cover_url,
description: event.description,
hosts: event.hosts || [],
}))
}
export async function GET(request: NextRequest) {
try {
const lumaApiKey = process.env.LUMA_API_KEY
const communityKey = process.env.LUMA_API_KEY
const hackathonKey = process.env.LUMA_HACKATHONS_API_KEY
if (!lumaApiKey) {
console.error('LUMA_API_KEY environment variable is not set')
if (!communityKey && !hackathonKey) {
console.error('No Luma API keys configured (LUMA_API_KEY / LUMA_HACKATHONS_API_KEY)')
return NextResponse.json({ error: 'API configuration error' }, { status: 500 })
}
// Extract query parameters from the request
const { searchParams } = new URL(request.url)
const after = searchParams.get('after')
const before = searchParams.get('before')
// Build the Luma API URL with query parameters
const lumaUrl = new URL('https://public-api.lu.ma/public/v1/calendar/list-events')
const calendarFetches: Array<Promise<Awaited<ReturnType<typeof fetchLumaCalendar>>>> = []
if (communityKey)
calendarFetches.push(fetchLumaCalendar(communityKey, 'community', after, before))
if (hackathonKey)
calendarFetches.push(fetchLumaCalendar(hackathonKey, 'hackathon', after, before))
if (after) {
lumaUrl.searchParams.append('after', after)
}
if (before) {
lumaUrl.searchParams.append('before', before)
}
const results = await Promise.allSettled(calendarFetches)
// Fetch events from Luma API
const response = await fetch(lumaUrl.toString(), {
method: 'GET',
headers: {
accept: 'application/json',
'x-luma-api-key': lumaApiKey,
},
})
if (!response.ok) {
console.error('Luma API error:', response.status, response.statusText)
return NextResponse.json(
{
error: 'Failed to fetch events from Luma',
status: response.status,
},
{ status: response.status }
)
}
const data: LumaResponse = await response.json()
const launchWeekEvents = data.entries
.filter(({ event }: { event: LumaPayloadEvent }) => event.visibility === 'public')
.map(({ event }: { event: LumaPayloadEvent }) => ({
id: event.api_id,
start_at: event.start_at,
end_at: event.end_at,
name: event.name,
city: event.geo_address_json?.city,
country: event.geo_address_json?.country,
url: event.url,
timezone: event.timezone,
cover_url: event.cover_url,
description: event.description,
hosts: event.hosts || [],
}))
const events = results
.flatMap((result) => {
if (result.status === 'fulfilled') return result.value
Sentry.captureException(result.reason)
console.error(result.reason)
return []
})
.sort((a, b) => new Date(a.start_at).getTime() - new Date(b.start_at).getTime())
return NextResponse.json({
success: true,
events: launchWeekEvents,
total: launchWeekEvents.length,
filters: {
after,
before,
},
events,
total: events.length,
filters: { after, before },
})
} catch (error) {
Sentry.captureException(error)
console.error('Error fetching meetups from Luma:', error)
console.error('Error fetching events from Luma:', error)
return NextResponse.json(
{
error: 'Internal server error',
+17 -9
View File
@@ -20,9 +20,10 @@ const EventsContext = createContext<EventsContextValue | undefined>(undefined)
interface EventsProviderProps {
children: ReactNode
notionEvents: SupabaseEvent[]
mdxEvents: SupabaseEvent[]
}
export function EventsProvider({ children, notionEvents }: EventsProviderProps) {
export function EventsProvider({ children, notionEvents, mdxEvents }: EventsProviderProps) {
const [lumaEvents, setLumaEvents] = useState<SupabaseEvent[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
@@ -42,9 +43,12 @@ export function EventsProvider({ children, notionEvents }: EventsProviderProps)
if (data.success) {
const transformedEvents: SupabaseEvent[] = data.events.map((event: any) => {
let categories: string[] = []
const isMeetup = event.name.toLowerCase().includes('meetup')
if (isMeetup) categories.push('meetup')
// Categorize by the originating Luma calendar: events from the
// Supabase Hackathons calendar → `hackathon`; everything from the
// Supabase Community Events calendar → `community`.
const categories: string[] = [
event?.calendar === 'hackathon' ? 'hackathon' : 'community',
]
const rawUrl = event?.url || ''
let safeUrl: string | undefined
@@ -73,7 +77,8 @@ export function EventsProvider({ children, notionEvents }: EventsProviderProps)
location: new Intl.ListFormat('en', { style: 'narrow', type: 'unit' }).format(
[event?.city, event?.country].filter(Boolean)
),
hosts: isMeetup || event?.hosts?.length === 0 ? [SUPABASE_HOST] : event?.hosts || [],
// All Luma events are Supabase-hosted regardless of which calendar they're from.
hosts: [SUPABASE_HOST],
source: 'luma' as const,
disable_page_build: true,
link: safeUrl ? { href: safeUrl, target: '_blank' as const } : undefined,
@@ -91,14 +96,17 @@ export function EventsProvider({ children, notionEvents }: EventsProviderProps)
fetchLumaEvents()
}, [])
// Merge Notion (server) + Luma (client) events, excluding past events
// Merge Notion (server) + mdx (server) + Luma (client) events, showing only today and future.
const allEvents = useMemo(() => {
const now = new Date()
return [...notionEvents, ...lumaEvents].filter((event) => {
const startOfToday = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
)
return [...notionEvents, ...mdxEvents, ...lumaEvents].filter((event) => {
const eventDate = new Date(event.end_date || event.date)
return eventDate >= now
return eventDate >= startOfToday
})
}, [notionEvents, lumaEvents])
}, [notionEvents, mdxEvents, lumaEvents])
const categories = useMemo(() => {
const counts: { [key: string]: number } = { all: 0 }
+6 -3
View File
@@ -1,6 +1,6 @@
import { EventClientRenderer } from '~/components/Events/new/EventClientRenderer'
import { breadcrumbs } from '~/lib/breadcrumbs'
import { getNotionEvents } from '~/lib/events'
import { getMdxEvents, getNotionEvents } from '~/lib/events'
import { breadcrumbListSchema, serializeJsonLd } from '~/lib/json-ld'
import type { Metadata } from 'next'
@@ -11,7 +11,10 @@ export const metadata: Metadata = {
}
export default async function EventsPage() {
const notionEvents = await getNotionEvents()
const [notionEvents, mdxEvents] = await Promise.all([
getNotionEvents(),
Promise.resolve(getMdxEvents()),
])
return (
<>
@@ -21,7 +24,7 @@ export default async function EventsPage() {
__html: serializeJsonLd(breadcrumbListSchema(breadcrumbs.eventsIndex)),
}}
/>
<EventClientRenderer notionEvents={notionEvents} />
<EventClientRenderer notionEvents={notionEvents} mdxEvents={mdxEvents} />
</>
)
}
@@ -28,12 +28,14 @@ export function EventBanner() {
</Badge>
)}
</div>
<p
className="text-lg font-medium text-foreground-light"
title={`Hosted by ${formatHosts(featuredEvent.hosts).fullList}`}
>
{formatHosts(featuredEvent.hosts).displayText}
</p>
{featuredEvent.hosts.length > 0 && (
<p
className="text-lg font-medium text-foreground-light"
title={`Hosted by ${formatHosts(featuredEvent.hosts).fullList}`}
>
{formatHosts(featuredEvent.hosts).displayText}
</p>
)}
</div>
<p className="text-foreground-light line-clamp-3 lg:line-clamp-4">
@@ -19,9 +19,15 @@ function EventBannerSection() {
)
}
export function EventClientRenderer({ notionEvents }: { notionEvents: SupabaseEvent[] }) {
export function EventClientRenderer({
notionEvents,
mdxEvents,
}: {
notionEvents: SupabaseEvent[]
mdxEvents: SupabaseEvent[]
}) {
return (
<EventsProvider notionEvents={notionEvents}>
<EventsProvider notionEvents={notionEvents} mdxEvents={mdxEvents}>
<DefaultLayout className="flex flex-col">
<EventsContainer className="border-x border-b py-8">
<h1 className="h3 p-0! m-0!">
@@ -1,13 +1,13 @@
'use client'
import { Input_Shadcn_ as Input } from 'ui'
import { SearchIcon } from 'lucide-react'
import { Badge } from 'ui'
import { useEvents } from '~/app/events/context'
import { SearchIcon } from 'lucide-react'
import { Badge, Input_Shadcn_ as Input } from 'ui'
const CATEGORIES_FILTERS = [
{ name: 'All', value: 'all' },
{ name: 'Conference', value: 'conference' },
{ name: 'Community Event', value: 'community' },
{ name: 'Meetup', value: 'meetup' },
{ name: 'Workshop', value: 'workshop' },
{ name: 'Hackathon', value: 'hackathon' },
+15 -12
View File
@@ -8,6 +8,7 @@ import { Badge, Button, cn } from 'ui'
const CATEGORIES_FILTERS = [
{ name: 'All', value: 'all' },
{ name: 'Community Event', value: 'community' },
{ name: 'Meetup', value: 'meetup' },
{ name: 'Conference', value: 'conference' },
{ name: 'Workshop', value: 'workshop' },
@@ -83,7 +84,7 @@ export function EventList() {
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<h3 className="leading-3">{event.title}</h3>
<h3 className="leading-snug">{event.title}</h3>
{event.isSpeaking && (
<Badge variant="success" className="flex items-center gap-1">
Speaking
@@ -107,18 +108,20 @@ export function EventList() {
)}
</div>
<div className="flex gap-2 items-center text-sm text-foreground-light">
<div className="size-5 rounded-full border bg-linear-to-br from-background-surface-100 to-background-surface-200 relative">
{event.hosts[0]?.avatar_url && (
<img
src={event.hosts[0].avatar_url}
alt={event.hosts[0].name || 'Host image'}
className="absolute inset-0 w-full h-full object-cover rounded-full"
/>
)}
{event.hosts.length > 0 && (
<div className="flex gap-2 items-center text-sm text-foreground-light">
<div className="size-5 rounded-full border bg-linear-to-br from-background-surface-100 to-background-surface-200 relative">
{event.hosts[0]?.avatar_url && (
<img
src={event.hosts[0].avatar_url}
alt={event.hosts[0].name || 'Host image'}
className="absolute inset-0 w-full h-full object-cover rounded-full"
/>
)}
</div>
Hosted by {formatHosts(event.hosts).displayText}
</div>
Hosted by {formatHosts(event.hosts).displayText}
</div>
)}
</div>
<div className="flex items-center gap-2 relative z-10">
+132 -2
View File
@@ -16,9 +16,12 @@
* Are you speaking at this event? -> multi_select
* Participation -> multi_select
*/
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { queryDatabase } from 'lib/notion'
import { SUPABASE_HOST, SupabaseEvent } from './eventsTypes'
import { EventHost, SUPABASE_HOST, SupabaseEvent } from './eventsTypes'
// The actual DB ID (child database inside the page)
const NOTION_EVENTS_DB_ID_FALLBACK = '21b5004b775f8058872fe8fa81e2c7ac'
@@ -70,6 +73,12 @@ function getMultiSelect(page: any, name: string): string[] {
return prop.multi_select.map((s: any) => s.name)
}
function getSelect(page: any, name: string): string {
const prop = page.properties[name]
if (!prop || prop.type !== 'select') return ''
return prop.select?.name ?? ''
}
// ─── Main fetch ─────────────────────────────────────────────────────────────
/**
@@ -107,6 +116,10 @@ export const getNotionEvents = async (): Promise<SupabaseEvent[]> => {
const rawMeetingLink = getUrl(page, 'Book Meeting Link')
const meetingLink = isSafeHttpUrl(rawMeetingLink) ? rawMeetingLink : ''
const location = getRichText(page, 'Location')
const notionType = getSelect(page, 'Type')
// "Conference" events are third-party — we attend but don't host, so no
// "Hosted by" line. "Supabase event" → host = Supabase.
const isConferenceType = notionType.toLowerCase() === 'conference'
const categories = ['conference']
const speakingAnswers = getMultiSelect(page, 'Are you speaking at this event?')
const isSpeaking = speakingAnswers.includes('Yes')
@@ -126,7 +139,7 @@ export const getNotionEvents = async (): Promise<SupabaseEvent[]> => {
categories,
timezone: '',
location,
hosts: [SUPABASE_HOST],
hosts: isConferenceType ? [] : [SUPABASE_HOST],
source: 'notion',
disable_page_build: true,
isSpeaking,
@@ -140,3 +153,120 @@ export const getNotionEvents = async (): Promise<SupabaseEvent[]> => {
return []
}
}
// ─── MDX events ─────────────────────────────────────────────────────────────
const EVENTS_DIRECTORY = '_events'
const FILENAME_DATE_PREFIX_LENGTH = 11
type MdxHost = { name?: string; avatar_url?: string }
type MdxFrontmatter = {
title?: string
subtitle?: string
description?: string
meta_description?: string
type?: string
date?: string
end_date?: string
timezone?: string
categories?: string[]
tags?: string[]
onDemand?: boolean
disable_page_build?: boolean
hosts?: MdxHost[]
main_cta?: { url?: string; target?: '_blank' | '_self'; label?: string }
}
function mdxSlugFromFilename(filename: string): string {
// Matches the slug produced by getAllPostSlugs for `_events` (keeps any leading
// underscore from `YYYY-MM-DD__name.mdx`), so /events/{slug} lands on the built page.
return filename.replace(/\.mdx$/, '').substring(FILENAME_DATE_PREFIX_LENGTH)
}
function mdxHostsToEventHosts(hosts: MdxHost[] | undefined): EventHost[] {
if (!hosts || hosts.length === 0) return [SUPABASE_HOST]
return hosts.map((host, i) => ({
id: `mdx-host-${i}`,
email: '',
name: host.name ?? null,
first_name: host.name ?? null,
last_name: null,
avatar_url: host.avatar_url ?? '',
}))
}
function startOfTodayUtc(): Date {
const now = new Date()
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
}
/**
* Read all events under `_events/` and return today-and-future events.
* Past events are excluded (by end_date when present, otherwise start date).
*/
export const getMdxEvents = (): SupabaseEvent[] => {
const postDirectory = path.join(process.cwd(), EVENTS_DIRECTORY)
let fileNames: string[]
try {
fileNames = fs.readdirSync(postDirectory)
} catch (error) {
console.error('Error reading _events directory:', error)
return []
}
const today = startOfTodayUtc()
return fileNames
.filter((filename) => filename.endsWith('.mdx'))
.map((filename): SupabaseEvent | null => {
try {
const fullPath = path.join(postDirectory, filename)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data } = matter(fileContents) as unknown as { data: MdxFrontmatter }
if (!data.title || !data.date) return null
const eventEnd = new Date(data.end_date ?? data.date)
if (eventEnd < today) return null
const slug = mdxSlugFromFilename(filename)
const categories = data.categories ?? (data.type ? [data.type] : [])
// Webinars don't show a "Hosted by" line — drop hosts entirely.
const isWebinar = data.type === 'webinar' || categories.includes('webinar')
const rawCtaUrl = data.main_cta?.url ?? ''
const isExternalCta = /^https?:\/\//i.test(rawCtaUrl)
const safeExternalCta = isExternalCta && isSafeHttpUrl(rawCtaUrl) ? rawCtaUrl : ''
const internalPath = `/events/${slug}`
const href = safeExternalCta || internalPath
const target = safeExternalCta ? '_blank' : '_self'
return {
slug,
type: data.type ?? 'event',
title: data.title,
date: data.date,
end_date: data.end_date,
description: data.meta_description ?? data.description ?? data.subtitle ?? '',
thumb: '',
cover_url: '',
path: internalPath,
url: href,
tags: data.tags ?? categories,
categories,
timezone: data.timezone ?? '',
location: '',
hosts: isWebinar ? [] : mdxHostsToEventHosts(data.hosts),
source: 'mdx',
onDemand: data.onDemand,
disable_page_build: data.disable_page_build,
link: { href, target, label: data.main_cta?.label },
}
} catch (error) {
console.error(`Error parsing mdx event ${filename}:`, error)
return null
}
})
.filter((e): e is SupabaseEvent => e !== null)
}
+1 -1
View File
@@ -45,7 +45,7 @@ export interface SupabaseEvent {
timezone: string
location: string
hosts: EventHost[]
source: 'luma' | 'notion'
source: 'luma' | 'notion' | 'mdx'
end_date?: string
onDemand?: boolean
disable_page_build?: boolean
+1
View File
@@ -62,6 +62,7 @@
"ASSET_CDN_S3_ENDPOINT",
"SITE_NAME",
"LUMA_API_KEY",
"LUMA_HACKATHONS_API_KEY",
"NEXT_RUNTIME",
// These env vars are used by some community and marketing integrations
"SENTRY_DSN_COMMUNITY",