Fall Sale — save up to 35% on selected prints. Prices as marked.
Georgian Bay Artists

Architecture

A deep-dive into how this site is built — the request path, the deploy pipeline, the data model and every flow that writes to it — plus the guards that stop it from quietly going wrong.

Executive summary

A storefront for one artist’s paintings and photographs: a filterable gallery, a page per work with print sizes and prices, a map of where each work was made, time-lapse films of the paintings being made, a gift-shop builder, and an admin CMS behind a password. Prints are ordered by enquiry — the order form emails the artist and the sale completes by email, so the site takes no payments and holds no card data.

It runs on fully managed infrastructure — Next.js on Vercel, Postgres on Neon, media on Vercel Blob, email through Resend — and is run by one person with no developer on call. The design leans accordingly: mistakes are made to fail the build rather than reach production, and scheduled jobs email when something goes wrong rather than waiting on a dashboard someone has to watch.

Framework
Next.js 16 · App Router
Runtime
React 19 · Node
Data
Prisma 7 · Neon Postgres
Host
Vercel · Crons · Blob
Email
Resend
Styling
Tailwind CSS v4
Tests
Vitest · Playwright · axe · ZAP
Captions
Claude API

Operations

Tests gate every deploy

Every pull request runs a dependency audit, the full unit suite, a production build and the Playwright suite against a throwaway Postgres. Vercel’s production build runs the unit suite again, so a failing test stops the deploy even if it slipped past review.

Four scheduled jobs

Vercel Crons: a health check every 10 minutes, a catalogue backup daily at 05:00 UTC, a prune of old analytics events and expired rate-limit counters at 05:30 UTC, and Instagram publishing Mon/Wed/Fri at 15:00 UTC. Each route rejects any request without the CRON_SECRET bearer token.

Failures email the owner

Every 10 minutes the health check confirms the database answers and four real pages render on the live domain, and emails through Resend (lib/notify.ts) once when something breaks and once when it recovers — never on every run. The Instagram job emails if its token is invalid, a publish fails, or a queued caption has gone stale.

Two kinds of analytics

Google Analytics 4, plus a first-party tracker writing one row per event to Postgres — no raw IP stored — with its own dashboard. The owner’s browsers are excluded at ingest, not filtered afterwards.

1 · Runtime request path

Every page is a React Server Component rendered on request (dynamic = "force-dynamic"), reading through a single Prisma client. Interactivity is confined to client-component islands — the gallery’s filter and sort, the size selector and order form, the map, the film player — and writes go through route handlers (public forms) or server actions (the admin).

Browser

Server-rendered HTML · client islands: gallery filter/sort, size selector + order form, Leaflet map, click-to-load film player · first-party analytics beacon

Next.js 16 on Vercel (Node runtime)

Proxy gives each page request a fresh CSP nonce · Server Components render every page per request · Route handlers /api/buy /api/contact /api/book /api/track /api/og-image /api/csp-report /api/cron/* · Server actions for the admin (session-checked)

Prisma 7 client (module singleton)

Adapter chosen by URL: PrismaNeonHttp for Neon — one HTTPS request per query, no connection pool to exhaust — or PrismaPg for a plain Postgres (CI and local E2E)

Neon Postgres (serverless)

Product · MakingOf · InstagramPost · AnalyticsEvent · ExcludedVisitor · FormRateLimit · HealthState · CspViolation

Alongside the database

Repository (/public)

catalogue images, served through the next/image optimiser

Vercel Blob

admin uploads, collectors' photos, films, backups

Resend

order, contact and booking emails; failure alerts

Kit

newsletter sign-up, posted from the browser

OpenStreetMap tiles

the Locations map

Meta Graph API · Claude API

Instagram publishing · caption drafts

Rendering on every request is a deliberate trade: an edit in the admin is live on the next page load with no revalidation logic, and the build never needs database access to render. The catalogue is small, so the cost — a database round trip per view — is acceptable.

2 · CI/CD & deployment topology

All work lands through pull requests into main. GitHub Actions (.github/workflows/e2e.yml) runs on every pull request and every push to main; Vercel deploys main to production on merge.

Feature branch

local: unit tests, typecheck, E2E on a throwaway Postgres

GitHub Actions

Postgres 16 service → npm ci → npm audit → npm run build → seed → Playwright + axe → ZAP scan

Merge to main

squash merge

Vercel build

prisma generate → prisma db push → vitest → next build

Production

CDN + Node functions → Neon

  • One build script everywhere. npm run build is prisma generate && prisma db push && vitest run && next build, in CI and on Vercel alike, so the unit suite gates production and not only pull requests.
  • No preview deployments. vercel.json disables deploys for the feature/, fix/, docs/, chore/, perf/, test/ and claude/ prefixes, and its ignoreCommand skips any other non-production build. CI is the pre-merge gate; visual checks happen locally against a production build.
  • Schema changes ship with the code. prisma db push runs on every build, so the column exists before the code that reads it is served. There is no migrations directory: changes must be additive or nullable, and anything destructive needs a hand-written plan.
  • E2E cannot touch production. The Playwright suite creates, edits and deletes rows. e2e/db-guard.ts refuses to start unless DATABASE_URL points at a local host — added after a local run once edited the live catalogue.

3 · Data model

Eight tables. Paintings and photographs share Product, distinguished by type. Other tables refer to a work by its id rather than a foreign key: a film can be published before its painting is in the catalogue and links itself up once the product exists, and analytics events outlive a deleted work.

Product

The catalogue — the site's source of truth

id
slug, PK — also the URL
type
"painting" | "photograph"
title · category · description
copy
image · blurDataURL
repo path or Blob URL · base64 placeholder
sizeType · price
keys into the size/price table in data/products.ts
inspirationImage · Lat · Lng
the source photo and where it was taken
homePhoto · homePhotoCredit
a collector's photo, stored stripped
isNew · onSale · soldOut · visible
merchandising flags
sortOrder
drag-to-reorder position
naturalWidth · naturalHeight
photographs: panorama detection

InstagramPost

A publishing queue with a state machine

status
pending → approved → posting → posted | skipped
type · productId
painting, photograph, reel, voting, press
imageUrl · caption
media and the approved text
scheduledAt
next free Mon/Wed/Fri slot, set on approval
mediaFingerprint
etag|content-length when the caption was written
instagramId · postedAt
set by the Graph API on publish

MakingOf

Time-lapse films of the paintings

videoUrl · posterUrl
Blob — too large to commit
productId
optional; links up when the work exists
description · seconds
caption and running time
visible · sortOrder
publishing and order

AnalyticsEvent

One row per page view or action

kind · action
pageview | action (buy_open, product_click, …)
path · productId · referrer
what, and from where
visitorId
anonymous first-party cookie
device · browser · os
parsed server-side from the user agent
country · city
coarse, from Vercel edge headers — no IP
indexes
createdAt · kind+createdAt · action · productId · visitorId

ExcludedVisitor

Browsers not counted — the owner's

visitorId
PK — the gba_vid cookie value
label
free text, e.g. "desktop Chrome"

FormRateLimit

Counters that stop the forms being a mail relay

key
PK — an HMAC of an IP or email address, never the raw value
windowStart · count
fixed window, reset in the same statement

HealthState

The health check's last result — one row

failing · changedAt
so it emails on a change, and knows how long it lasted
checkedAt · detail
when it last ran, and its full report

CspViolation

Policy violations browsers report — aggregated

directive · blocked · page
what, from which origin, on which page
count · firstSeen · lastSeen
one row per kind; unseen for 30 days → pruned

In code, not the database

Shared, versioned configuration

data/siteConfig.ts
brand, artist, domain, contacts, IDs
data/products.ts
SizeType union and the print price table
data/giftShop.ts
gift-shop products and image counts
app/globals.css @theme
brand colour tokens

4 · Key flows

Order a print, a card or a gift pack

The Buy Now form posts only ids and choices — which work, which size, framed or not, which images in a pack — to /api/buy. The server looks up the title and price itself (lib/order.ts) and refuses a hidden or sold-out work, a size the work isn’t sold in, or a pack with the wrong images, so no posted text can reach an email. It then sends the enquiry to the artist, with Reply-To set to the buyer, and a confirmation to the buyer. No payment is taken on the site. Contact and booking (/api/contact, /api/book) follow the same pattern. All three forms carry a hidden honeypot field; a submission that fills it gets a success response and sends nothing, so a bot learns nothing from being caught.

Add or edit a work (admin)

In the browser, the image is converted from HEIC if needed, downscaled to 2,400px JPEG and given a blur placeholder — which keeps uploads under the 4.5 MB server-action body limit. The server action checks the session, embeds the copyright notice in EXIF and XMP (pure JS; no exiftool in a serverless function), uploads to Blob under paintings/ or photographs/, and writes the row. The work is live on the next request.

Attach a collector’s photo

A photo of a work hanging in a buyer’s home replaces the generated room mockup on that work’s page. It is re-encoded with sharp, which drops all metadata — a phone photo taken at home carries that home’s GPS — and it fails closed: a file that can’t be read is refused, never published as-is. It gets no artist copyright, because it isn’t the artist’s photograph.

Delete a work or replace an image

Before deleting a Blob object, lib/blob-cleanup.ts counts references across products, Instagram posts and films; a file still in use elsewhere is kept.

Publish to Instagram

Captions are drafted by the Claude API into pending; approving one assigns the next free Mon/Wed/Fri slot. The cron checks the token, then re-reads the media’s etag and length and puts the post back to pending if the file changed since the caption was written (a repaint replaces the file behind an unchanged URL). Images outside Instagram’s 0.8–1.91 aspect range are letterboxed to a temporary Blob copy. Publishing is two Graph API calls — create a container, then publish it — with the row held in posting in between. A failure before publishing is safe to retry; one after is not, so a row stuck in posting is left for a person to check rather than retried into a duplicate public post.

Record an analytics event

The tracker posts to /api/track, which drops the event if the host is localhost or a preview deployment, the user agent is a bot or headless browser, or the visitor is excluded. Otherwise it parses the user agent, takes coarse location from Vercel’s headers, sets an anonymous httpOnly gba_vid cookie and writes one row. A daily cron deletes events older than three years with a single deleteMany.

Sell out an edition

soldOut is distinct from visible: a sold-out work keeps its page and stays indexed, its JSON-LD availability becomes SoldOut, prices and the order form give way to an enquiry panel, and it moves to its own section at the end of the gallery and out of the gift shop.

5 · Media pipeline

  • Catalogue images live in the repository under public/paintings and public/photographs, so the artwork is version-controlled. They are served through the next/image optimiser, resized per viewport. A new admin upload lives on Blob until it is committed the same way.
  • Blur-up placeholders come from data/blurs.ts for committed images, or the per-product blurDataURL made at upload. The generated file is stripped of metadata, and a unit test fails the build if metadata returns — it once took the placeholder file from 46 KB to 200 KB without anyone noticing.
  • Copyright metadata has two writers that agree: an exiftool script for committed files (EXIF, IPTC, XMP) and a pure-JS writer for uploads (EXIF, XMP). Both change metadata only, never pixel data; the notice itself is defined once, in lib/copyright.ts.
  • Films stay on Blob as MP4s with the index at the front (faststart), so playback begins before the file has fully arrived. The page renders a still poster; no <video> element exists until the visitor presses play, so no film data is downloaded unless asked for.
  • Share images come from /api/og-image, which composes a card with sharp from a work that is currently visible — a hard-coded image could name a work that has since been hidden.
  • Old URLs from the previous Squarespace site (/home/p/<slug>) redirect permanently to works still for sale; the rest return 404 on purpose rather than landing somewhere misleading.

6 · Tests & build guards

At the time of writing: about 440 unit and component tests (Vitest and Testing Library) and about 85 Playwright tests, one of them opt-in. Beyond ordinary behaviour tests, a set of guards turn whole classes of mistake into build failures. Most exist because the mistake happened once.

GuardFails the build when…
Brand coloura brand-green hex literal appears under app/, components/ or lib/ instead of the @theme token. There were 186, in four different hover greens.
Site identitythe brand, domain, email, phone, Instagram handle or analytics id is typed into code instead of read from data/siteConfig.ts.
Admin form fieldsa server action reads a checkbox its form never renders, or tests one against "off". A browser never sends "off"; both mistakes once shipped at the same time.
Collector photo privacythe home-photo path stops re-encoding, or starts stamping the artist’s copyright on someone else’s photograph.
Placeholder metadataimage metadata reappears in the blur placeholders shipped in the bundle.
Accessibilityaxe finds a WCAG 2.1 A/AA violation on any public page, the open mobile menu or the order form. Two rules the site already failed when this was wired in — colour contrast and colour-only links — are reported but not yet enforced.
Content-Security-Policyany public page, the signed-in admin or a playing film raises a policy violation (e2e/csp.spec.ts), or a page is served without a fresh nonce.
Security headersany response is missing the frame, sniffing, referrer or permissions header.
Dependency audita production dependency has a critical security advisory (npm audit, the first step in CI).
Security scanOWASP ZAP’s baseline scan of the CI build raises a finding not triaged in .zap/rules.tsv, where each accepted one carries its reason.
E2E databaseDATABASE_URL is not local — the suite refuses to start.
Archive before deletea destructive script’s archive would overwrite an earlier one; the write fails and nothing is deleted.

7 · Security & privacy

  • Admin authentication is a single password (one user) exchanged for an iron-session cookie: encrypted, httpOnly, SameSite=Lax, Secure in production, eight hours. Every server action that changes data checks that session before doing anything.
  • Login attempts are limited to five per fifteen minutes per client IP. The limiter is in memory and per instance, so it slows brute force rather than guaranteeing a global limit.
  • Cron routes require the CRON_SECRET bearer token and return 401 without it.
  • Public forms validate names and emails server-side, and all three carry a honeypot. The booking form also rejects a submission made faster than a person could type it, and the contact form one with no spaces in it. The site stores no payment details because it takes no payments.
  • The forms can’t be used as a mail relay. Each one emails a confirmation to the address typed in, from the site’s own domain, so each is rate-limited in Postgres across every serverless instance: per client IP and form, per recipient address, and a daily total (lib/form-rate-limit.ts). The confirmations repeat nothing the visitor typed — not even their name — and the stored keys are HMACs, never raw IP or email addresses. Over the IP limit, nothing is sent; over the others, the artist still gets the enquiry and only the confirmation is held back.
  • Analytics stores no IP address: location is the coarse country and city Vercel supplies, and visitors are counted by a random httpOnly cookie. Headless browsers and bots are dropped at ingest.
  • Other people’s photographs are re-encoded to remove location metadata, credited as their owners asked, and removable from the edit form.
  • Secrets live in Vercel environment variables. The repository carries only .env.local.example, with placeholder values.

8 · Security testing & resilience

What is in place, and what is not, stated plainly — including the things that haven’t been done.

Automated security tests

These run in CI on every pull request:

  • Admin login rejects a wrong password, and locks out after five failed attempts (e2e/admin.spec.ts, e2e/zz-rate-limit.spec.ts).
  • Admin pages redirect to the login page without a session.
  • The order endpoint refuses a size the work isn’t sold in and a work that doesn’t exist; every form sends nothing once over its limit; and no confirmation repeats a submitted name or title (__tests__/form-email-abuse.test.ts).
  • The order and contact endpoints reject requests with missing fields, accept honeypot submissions without sending anything, and the contact endpoint rejects a message with no spaces (e2e/api-validation.spec.ts).
  • Collectors’ photos are re-encoded, never published with their location data, and never stamped with the artist’s copyright (unit tests).
  • The E2E suite refuses to run against a database that isn’t local.
  • Every form posts, so one sent before JavaScript loads keeps the visitor’s email and message out of the URL, and out of analytics (e2e/forms.spec.ts).

Automated scanning (DAST)

After the E2E suite passes, CI starts the same production build again and runs the OWASP ZAP baseline scan against it: a spider crawls the site (over a thousand URLs) and ZAP’s passive rules check every response — headers, cookies, the policy, caching, information leaks. It sends no attack payloads and never touches production. The ZAP image is pinned, so a new release can’t fail an unrelated pull request. Any finding fails the job unless .zap/rules.tsv records it as triaged, with the reason; the report is attached to the run.

Its first run found four things, and all four were fixed rather than accepted. The contact form, sent before JavaScript loaded, put the visitor’s email and message in the URL; every form now posts. Responses announced X-Powered-By: Next.js. And the cross-origin opener and resource policies were missing. What it still reports is accepted on the record: the policy is report-only for now, GA4’s script can’t carry a subresource-integrity hash because Google changes it, and Cross-Origin-Embedder-Policy would block the map and analytics for an isolation the site doesn’t need. The public forms carry no anti-CSRF token because they act for no one — there is no session for a forged request to ride — while the admin’s server actions reject a request from another origin, behind a SameSite cookie.

Dependencies

Dependabot opens grouped update PRs weekly for npm packages and monthly for GitHub Actions, and GitHub raises security alerts with automatic fix PRs; each one runs the full CI like any other change. Installs are reproducible — package-lock.json is committed and CI uses npm ci — and CI runs npm audit on production dependencies, failing on any critical advisory.

The audit is held at critical rather than high for one known exception: the Prisma CLI’s own dependencies, which no 7.x release fixes and which run only during the build, never when a visitor loads a page.

Not done yet

AreaStatus
Penetration testingThe automated scan is passive. No active scan, which sends attack payloads, and no manual penetration test has been carried out.
External uptime monitoringThe health check runs on Vercel, so it can’t see Vercel itself or the domain going down; nothing outside Vercel watches the site yet.
Load testing to failureThe load test is capped at 50 visitors on purpose, and the site was comfortable there, so where it stops coping is still unmeasured.
Static analysisTypeScript and ESLint only; no CodeQL, Snyk or secret scanning.

DDoS and traffic spikes

Vercel applies automatic DDoS mitigation and system-level traffic filtering to every request before it reaches the application, and Attack Mode can be switched on for the project during an attack — it challenges browsers while letting known good bots through. The repository defines no firewall rules of its own. Because pages render per request against a serverless database over HTTP, a spike turns into more function invocations and queries rather than exhausted connections.

Load testing

load/visitors.js is a k6 script that simulates visitors: each one loads the gallery, a painting, a photograph or the photo gallery, then the gift shop, map or films, pausing one to three seconds between pages the way a person does. It reads the works from the sitemap, so it follows the live catalogue. It makes read-only GET requests for pages alone — no forms, no admin, nothing written — and k6 runs no JavaScript, so analytics never sees it. It stops itself if more than 1% of requests fail or the 95th percentile passes three seconds, and it has no default target, so nobody runs it against production by accident.

Against production on 28 September 2026 it ramped to 50 simultaneous visitors over three and a half minutes, about 24 page renders a second at the peak, every one a function invocation with its Neon queries:

MeasureResult
Pages served2,849, none failed
Median135 ms
95th percentile194 ms (slowest page type: the films, 251 ms)
99th percentile275 ms
Slowest request852 ms

Response times stayed flat as the load rose, which says the ceiling is well above 50. How far above was not tested: the run is capped deliberately, because it runs against the live site.

HTTP security headers

Every response — pages, API routes, the admin — carries X-Frame-Options: DENY (no other site may frame the pages), X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, and a Permissions-Policy that switches off the camera, microphone, geolocation, payment and other features the site never uses, and Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy set to same-origin, so no other site gets a handle on its windows or can embed its images. Nothing announces the framework. They are set once, in lib/security-headers.ts, and checked by a unit test and by an E2E test that reads real responses. Strict-Transport-Security comes from Vercel, with a two-year max-age.

Content-Security-Policy

Nonce-based and strict. proxy.ts gives every page request a fresh nonce; a script runs only if it carries it, or was loaded by one that did ('strict-dynamic') — no 'unsafe-inline' for scripts and no host allowlist. Next.js stamps the nonce on its own scripts, and the GA4 tags take it from the layout. Nonces need every page rendered per request, which this site already does, so they cost nothing extra here. Each outside service is named once: analytics, the newsletter, the map tiles and iframe, stored images and films.

Two deliberate allowances. Styles allow inline, because React renders style attributes that nonces can’t cover. And the admin alone may use blob: workers and eval, for the converter that turns iPhone photos into JPEGs; the public site gets neither.

It is sent as report-only for now: violations are reported to /api/csp-report, aggregated into CspViolation (directive, blocked origin, page, count) and nothing is blocked. An E2E test loads every page, the signed-in admin and a playing film and fails on any violation; once real traffic is quiet too, one constant switches it to enforcing.

9 · Build & runtime notes

  • No interactive transactions. The Neon HTTP adapter runs single statements, so the admin avoids batch operations: reordering is a loop of single updates, and the analytics prune is one deleteMany.
  • Connection URL lives in prisma.config.ts (Prisma 7), which loads .env.local over .env the way Next.js does, and never overrides a variable already set — so an exported local DATABASE_URL always wins.
  • Server-action body limit is raised to 4.5 MB, matching Vercel’s function request limit; images are downscaled in the browser to fit.
  • Identity and theme are data. Whose site this is lives in data/siteConfig.ts and the brand colours in Tailwind @theme tokens, which is what makes the repository a template: a second artist forks it and follows FORKING.md.
  • Locally, .env points at the production database, so anything that writes is run against a throwaway Postgres and a production build.

10 · Backups & durability

  • Daily catalogue backup. A cron writes a JSON snapshot of the Product table to Blob (backups/catalog-YYYY-MM-DD.json) and deletes snapshots older than seven days. npm run db:export takes one by hand.
  • The artwork is in git. Committed catalogue images are versioned with the code; films are uploaded from their local source files by scripts/publish-films.ts.
  • Destructive scripts archive first and default to a dry run. The Blob pruner checks every product, film and Instagram post for references before deleting, and archives each file it removes; the analytics purge writes the rows it will delete to a file named to the second, and refuses to overwrite an earlier one.
  • Not in the daily backup: films, the Instagram queue and analytics. Films can be re-uploaded from their source files; the other two are operational data.

11 · Decisions & trade-offs

DecisionWhyCost
Render every page per requestAdmin edits are live immediately; no cache invalidation to get wrongA database read per page view — fine at this catalogue size
Neon over HTTPServerless-friendly: no pool to exhaust, no connection stormsSingle statements only; no interactive transactions
prisma db push, no migrationsSchema ships with the code in the same buildAdditive changes only; destructive ones need a manual plan
Catalogue images in the repoArtwork versioned with the code and served from the CDNNew uploads live on Blob until committed
No preview deploymentsCI on a throwaway database is the gate; nothing half-built is publicVisual review happens locally, not on a shareable URL
First-party analytics beside GA4Owns the data; excludes the owner at ingestAnother table to prune and a dashboard to maintain
Orders by email, not checkoutNo payment data, no PCI scope, a personal saleEvery order is handled by hand

12 · Repository map

app/
  page.tsx · HomeClient.tsx        gallery (server page + client filter/sort)
  products/[id]  photographs/[id]  a work: sizes, order form, JSON-LD, room view
  locations/  making-of/           map of where works were made · films
  gift-shop/                       calendar, card and postcard pack builders
  about/  faq/  contact/  book/    static and form pages
  for-artists/  architecture/      the pitch · this page
  admin/                           CMS, Instagram queue, analytics dashboard
  actions/                         server actions: admin, instagram, analytics
  api/                             buy · contact · book · track · og-image · cron/*
  home/p/[slug]/                   legacy Squarespace URL redirects
  sitemap.ts · robots.ts           generated from siteConfig
proxy.ts                           per-request CSP nonce for every page
components/                        Navigation, ProductCard, GalleryGrid,
                                   BuyNowButton, MakingOfVideo, LocationMap, …
lib/
  db.ts                            Prisma singleton; adapter by URL
  csp.ts · csp-report.ts           the policy · parsing violation reports
  session.ts · rate-limit.ts       admin session · login limiter
  order.ts                         what an order is: server-side titles and prices
  form-rate-limit.ts               Postgres-backed limits for the public forms
  analytics.ts                     UA parsing, bot and host filters
  instagram.ts                     caption drafting, price ranges, scheduling
  instagram-publish.ts             two-step Graph API publish
  blob-cleanup.ts                  reference counting before delete
  home-photo.ts                    re-encode collectors' photos (strips GPS)
  image-copyright.ts               stamp the artist's notice on uploads
  availability.ts · json-ld.ts     sold-out rules · structured data
data/                              siteConfig, price table, seed catalogue, blurs
prisma/                            schema.prisma · seed.ts
scripts/                           films, mockups, blurs, copyright, backups,
                                   Blob pruner, analytics purge
__tests__/                         Vitest unit, component and source-scan tests
e2e/                               Playwright specs, axe scans, db-guard
.github/workflows/e2e.yml          audit, build, E2E + ZAP scan on every PR and push to main
.zap/rules.tsv                     ZAP findings triaged as accepted, with reasons
load/visitors.js                   k6 load test: simulated visitors, read-only
.github/dependabot.yml             weekly npm and monthly Actions updates
vercel.json                        branch deploy rules · crons

The Studio List

New paintings, first.

Be the first to see new Georgian Bay paintings and prints, hear about collection releases, and get subscriber-only sale previews. A few emails a season — no clutter.

Unsubscribe anytime. We never share your email.