Skip to content

Site Development Guide

The site/ directory contains the v2.0 Vinny frontend — a server-rendered Astro site deployed to Cloudflare Pages. It reads from the same D1 database and R2 bucket used by the Python scraper pipeline.


Overview

flowchart LR
    A["Cloudflare D1\n(SQLite)"] --> B["Astro SSR\n(server-rendered)"]
    C["Cloudflare R2\n(images)"] --> B
    B --> D["Cloudflare Pages\nvinny.vegas"]
    D --> E["User Browser"]

    style A fill:#059669,color:#fff
    style C fill:#d97706,color:#fff
    style D fill:#7c3aed,color:#fff
Layer Tech
Framework Astro 6 (SSR, output: "server")
Adapter @astrojs/cloudflare
Styling Tailwind CSS v3
UI islands React (for future interactive components)
Package manager bun
Database Cloudflare D1 (vinny-vegas-events-db)
Images Cloudflare R2 (vinny-vegas-images)
Deploy Cloudflare Pages

Directory Structure

site/
├── astro.config.mjs        # Cloudflare adapter, Tailwind, React
├── wrangler.jsonc          # D1 + R2 bindings
├── tailwind.config.js
├── tsconfig.json
├── package.json
├── public/
│   └── robots.txt
└── src/
    ├── env.d.ts            # Cloudflare runtime types
    ├── layouts/
    │   └── Base.astro      # Shell: head, OG tags, nav, footer
    ├── lib/
    │   ├── types.ts        # TypeScript interfaces mirroring D1 schema
    │   ├── queries.ts      # D1 prepared-statement helpers
    │   ├── images.ts       # R2 URL builder + srcset helpers
    │   ├── seo.ts          # Meta/OG/JSON-LD generators per page type
    │   └── safe.ts         # Null-safe rendering helpers (see Null-Safety section)
    ├── components/
    │   ├── Nav.astro
    │   ├── EventCard.astro
    │   ├── EventGrid.astro
    │   ├── VenueCard.astro
    │   ├── ArtistCard.astro
    │   ├── PricingTable.astro
    │   ├── StreamingLinks.astro
    │   └── TableRangeFilters.tsx  # React island — ppg/guest sliders
    └── pages/
        ├── index.astro           # Homepage
        ├── events/
        │   ├── index.astro       # Event listing with venue filter
        │   └── [key].astro       # Event detail by composite_key
        ├── venues/
        │   ├── index.astro       # Venue listing
        │   └── [id].astro        # Venue detail + upcoming events
        ├── artists/
        │   └── [id].astro        # Artist bio, streaming, upcoming shows
        └── tables.astro          # VIP table pricing comparison

Local Development

Prerequisites

  • bun installed (brew install bun)
  • wrangler installed globally (bun add -g wrangler)
  • sqlite3 CLI available (comes with macOS)

First-time setup

cd site
bun install

Seed the local D1 database

The astro dev command uses a local SQLite file (inside site/.wrangler/) to simulate D1. It starts empty — you need to seed it from the remote database before the site will render real data.

# From the repo root:
just site-seed-local

This command: 1. Exports the remote D1 database to /tmp/vinny-d1-export.sql 2. Finds the local SQLite file inside site/.wrangler/state/ 3. Applies the schema + data to it

Note: The local SQLite file is created the first time astro dev starts. If just site-seed-local says "No local D1 sqlite found", start the dev server once, then run it again.

Start the dev server

cd site
astro dev
# or via bun:
bun run dev

The dev server proxies D1 and R2 bindings locally via wrangler. Open http://localhost:4321.

Re-seeding after a scrape run

After a new scrape + D1 import, refresh your local data:

just site-seed-local

Pages & Routes

Route Data Description
/ Next 7 days events + venues Hero, upcoming grid, venue cards
/events All upcoming, ?venue= filter Filterable event grid
/events/[key] Single event by composite_key Full detail: image, pricing, streaming, ticket CTA
/venues All venues with event counts Venue cards
/venues/[id] Venue + its events Venue info + upcoming events
/artists/[id] Artist + headliner events Bio, streaming embeds, upcoming shows
/tables table_tiers joined with events Sortable pricing comparison

D1 Access Pattern

In page frontmatter, access the D1 binding through Astro.locals.runtime.env:

const db = Astro.locals.runtime.env.DB;
const events = await getUpcomingEvents(db, 20);

All query helpers live in src/lib/queries.ts and use prepared statements:

// src/lib/queries.ts
export async function getUpcomingEvents(db: D1Database, limit = 20): Promise<Event[]> {
  const result = await db
    .prepare(`SELECT * FROM events WHERE event_date >= date('now') ORDER BY event_date ASC LIMIT ?`)
    .bind(limit)
    .all<Event>();
  return result.results;
}

Available query helpers

Function Returns
getUpcomingEvents(db, limit?) Next N events
getNextWeekEvents(db) Events in the next 7 days
getEventByKey(db, key) Single event by composite_key
getAllUpcomingEvents(db, venueId?) All upcoming, optional venue filter
getVenuesWithCounts(db) Venues with upcoming_count
getVenueById(db, id) Single venue
getEventsByVenue(db, venueId) Events for a venue
getArtistById(db, id) Single artist
getEventsByArtist(db, artistId) Events for an artist
getTablePricingView(db) All tiers joined with event info
getTableTiersByEvent(db, eventId) Tiers for a single event

Image Strategy

Images are served from R2 via the custom domain img.vinny.vegas. The base URL is stored in src/lib/images.ts:

https://img.vinny.vegas

Deprecated R2 URL

The old public URL https://pub-a209680121414327917920199a3f8c63.r2.dev was deprecated by Cloudflare in March 2026. All image URLs in D1 were migrated to img.vinny.vegas via scripts/migrate-r2-domain.sql. Ensure R2_PUBLIC_URL in .env points to https://img.vinny.vegas.

Pattern: artists/{artist-slug}_{size}.jpg

Sizes: small (250px), main (500px), medium (750px), large (1000px), hd

import { artistImageUrl, artistSrcSet, artistSlug } from "../lib/images";

const slug = artistSlug(event.performer); // "dom-dolla"
const src  = artistImageUrl(slug, "main");
const srcSet = artistSrcSet(slug);

Use resolveR2Url() for URLs already stored in D1 (they may be full URLs or relative paths):

import { resolveR2Url } from "../lib/images";

const imageUrl = resolveR2Url(event.artist_image_url); // always a full URL or null

SEO

Each page type has a generator in src/lib/seo.ts that returns a SeoMeta object consumed by Base.astro:

import { eventSeo } from "../lib/seo";

const seo = eventSeo(event);
// → { title, description, ogImage, canonicalUrl, jsonLd }

Base.astro automatically renders: - <title> and <meta name="description"> - Open Graph tags (og:title, og:description, og:image, og:url) - Twitter Card tags - <script type="application/ld+json"> (when jsonLd is provided) - Canonical URL

JSON-LD schema types used per page: - Event detail → schema.org/Event - Venue detail → schema.org/NightClub - Artist detail → schema.org/Person


Null-Safety & Graceful Degradation

D1 data varies by venue and enriches over time — fields that are empty now may be populated later as events approach or as new venues are added. The rendering layer must never crash on missing data. The philosophy is:

Show what you have, hide what you don't, never crash.

The problem: Astro SSR silent failures

SSR Silent Failure Pattern

When template code throws during SSR, Astro renders the layout (head, nav, footer) but the <main> content is silently empty. The response is still HTTP 200, so there is no error visible to the user or in monitoring. The dev server shows the stack trace; production does not.

When template code throws during SSR, Astro renders the layout (head, nav, footer) but the <main> content is silently empty. The response is still HTTP 200, so there is no error visible to the user or in monitoring. The page just looks blank.

JSON.parse(\"null\") is valid JSON

JSON.parse("null") returns JavaScript null without throwing. Object.keys(null) then crashes with TypeError. This was the root cause of blank XS Nightclub pages — streaming_links_json was stored as the literal string "null" in D1.

This was discovered when XS Nightclub event pages (e.g., Kaskade at XS) rendered with completely empty <main> content. Root cause: streaming_links_json was stored as the literal string "null" in D1. JSON.parse("null") is valid JSON and returns JavaScript null without throwing — so the try/catch didn't catch it. Then Object.keys(null) threw a TypeError during SSR, silently killing the page.

The solution: src/lib/safe.ts

A centralized utility module with null-safe wrappers for the three most common crash-prone operations in templates:

import { safeFormatDate, safeParseJsonObject, safeInitial } from "../lib/safe";
Helper Wraps Returns on null/error
safeFormatDate(dateStr, fmt, fallback?) date-fns format(parseISO(...)) "TBD" (or custom fallback)
safeParseJsonObject(json, fallback) JSON.parse() + object validation The fallback object (e.g., {})
safeInitial(str, fallback?) str[0] character access "?" (or custom fallback)

safeFormatDate

Safely formats an ISO date string. Returns the fallback on null, undefined, or malformed input — never throws.

// Before (crashes on null event_date):
const displayDate = format(parseISO(event.event_date), "EEE, MMM d");

// After:
const displayDate = safeFormatDate(event.event_date, "EEE, MMM d");
// → "TBD" if event_date is null

Used in: EventCard.astro, events/[key].astro, tables.astro

safeParseJsonObject

Safely parses a JSON string and validates the result is a non-null, non-array object. Handles all edge cases that JSON.parse alone misses:

  • null / undefined input → fallback
  • "null" (literal string) → JSON.parse("null") returns JS null → fallback
  • Invalid JSON → parse error caught → fallback
  • Arrays or primitives → not an object → fallback
// Before (crashes when streaming_links_json is "null"):
const links = JSON.parse(event.streaming_links_json ?? "{}");

// After:
const links = safeParseJsonObject<StreamingLinksType>(event.streaming_links_json, {});

Used in: events/[key].astro

safeInitial

Safe first-character access for avatar fallbacks. "Dom Dolla"[0] works fine, but null[0] and undefined[0] throw.

// Before:
<span>{event.performer[0]}</span>

// After:
<span>{safeInitial(event.performer)}</span>
// → "?" if performer is null

Used in: events/[key].astro, artists/[id].astro, ArtistCard.astro

Null-coalescing in templates

For simple nullable fields in Astro templates (especially joined columns from D1), use the ?? operator with an em-dash fallback:

<td>{tier.event_date ?? "—"}</td>
<td>{tier.performer ?? "—"}</td>
<td>{tier.venue_name ?? "—"}</td>

Used in: PricingTable.astro (for optional joined fields from table_tiers)

Guidelines for new pages/components

  1. Never call format(parseISO(...)) directly — use safeFormatDate
  2. Never call JSON.parse() on D1 columns — use safeParseJsonObject
  3. Never index into a string (str[0]) without null-checking — use safeInitial
  4. Always use ?? for nullable fields rendered as text
  5. Wrap optional sections in {value && (...)} — they simply don't render when null
  6. Test with XS Nightclub events — they have the sparsest data and are the best canary for rendering issues

XS Nightclub = Best Canary

XS has the sparsest data of any venue. If your template renders correctly for XS events, it will work everywhere. Always test new pages and components against XS events first.


Building for Production

cd site
bun run build

Output goes to site/dist/. The Cloudflare adapter produces a Pages-compatible bundle with a _worker.js entry point.


Deploying to Cloudflare Pages

cd site
wrangler pages deploy dist --project-name vinny-vegas-site

Or connect the GitHub repo in the Cloudflare dashboard and set the build command to:

cd site && bun install && bun run build

Build output directory: site/dist

D1 and R2 bindings must be configured in the Pages project settings to match site/wrangler.jsonc.


Troubleshooting

D1_ERROR: no such table: events

Your local D1 database is empty. Run:

just site-seed-local

Invalid binding DB / Invalid binding IMAGES

The wrangler bindings in site/wrangler.jsonc must match the names used in src/env.d.ts (DB and IMAGES). Check that astro dev is running from inside the site/ directory.

Images not loading

R2 images are served via the custom domain https://img.vinny.vegas. Verify that:

  1. The R2 bucket vinny-vegas-images has the custom domain img.vinny.vegas configured in the Cloudflare dashboard
  2. R2_PUBLIC_URL=https://img.vinny.vegas is set in .env
  3. D1 image URLs use img.vinny.vegas (not the old pub-...r2.dev domain) — run scripts/migrate-r2-domain.sql if needed

Build fails with TypeScript errors

Run the type check standalone:

cd site && bunx tsc --noEmit

Page renders with empty <main> (HTTP 200 but no content)

Astro SSR silently swallows template errors. The layout renders (head, nav, footer) but the page body is empty. No error is shown to the user.

Diagnosis: 1. Check the Cloudflare Pages function logs for TypeError or ReferenceError 2. Look for operations on nullable fields: JSON.parse(), format(), str[0] 3. Test the same event locally with astro dev — the dev server shows the full stack trace

Fix: Use the safe helpers from src/lib/safe.ts. See the Null-Safety section above.

Canonical URLs pointing to old domain

If SEO audits show canonical URLs pointing to vinny-vegas-site.pages.dev instead of vinny.vegas, check the seo object in page frontmatter. All canonical URLs should use https://vinny.vegas/....


Last updated: 2026-03-03