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¶
buninstalled (brew install bun)wranglerinstalled globally (bun add -g wrangler)sqlite3CLI available (comes with macOS)
First-time setup¶
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.
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 devstarts. Ifjust site-seed-localsays "No local D1 sqlite found", start the dev server once, then run it again.
Start the dev server¶
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:
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:
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:
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:
| 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/undefinedinput → fallback"null"(literal string) →JSON.parse("null")returns JSnull→ 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¶
- Never call
format(parseISO(...))directly — usesafeFormatDate - Never call
JSON.parse()on D1 columns — usesafeParseJsonObject - Never index into a string (
str[0]) without null-checking — usesafeInitial - Always use
??for nullable fields rendered as text - Wrap optional sections in
{value && (...)}— they simply don't render when null - 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¶
Output goes to site/dist/. The Cloudflare adapter produces a Pages-compatible bundle
with a _worker.js entry point.
Deploying to Cloudflare Pages¶
Or connect the GitHub repo in the Cloudflare dashboard and set the build command to:
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:
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:
- The R2 bucket
vinny-vegas-imageshas the custom domainimg.vinny.vegasconfigured in the Cloudflare dashboard R2_PUBLIC_URL=https://img.vinny.vegasis set in.env- D1 image URLs use
img.vinny.vegas(not the oldpub-...r2.devdomain) — runscripts/migrate-r2-domain.sqlif needed
Build fails with TypeScript errors¶
Run the type check standalone:
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