An open-source web application for community gardens - a public blog, member directory, and admin tools, built as a reusable platform any garden can self-host.
The codebase is a single .NET Aspire solution that orchestrates an ASP.NET Core API, a React single-page frontend, and a SQL Server database, so a developer can clone the repo and run the entire stack with one command.
| Layer | Technology |
|---|---|
| Orchestration | .NET Aspire 13.4 AppHost |
| API | ASP.NET Core 10 with FastEndpoints 8 |
| Auth | ASP.NET Core Identity (Identity API endpoints, cookie auth) |
| Database | SQL Server (containerised via Aspire) + EF Core 10 |
| Frontend | React 19, React Router 7, Vite 8, TypeScript 5.9 |
| Rich text | tiptap 3 |
| Telemetry | OpenTelemetry (traces, metrics, logs) via Aspire ServiceDefaults |
- Mobile-responsive public site - the homepage and public pages reflow from desktop to phone, with a slide-in nav drawer on small screens
- Public blog with categories (Newsletters, Events, How-to, Announcements), slugs, featured images
- Admin console (
/admin) for member CRUD, blog post editing with rich-text + image upload, and tools - Wix blog importer that pulls existing posts from a
feed.xmlsource - Member export to Excel (.xlsx)
- Cookie-based auth with role-gated admin routes
- Single-binary publish: the Vite-built SPA is bundled into the API's
wwwrootfor production
.
├── CommonGround.AppHost/ Aspire orchestrator - start here
├── CommonGround.Server/ ASP.NET Core API
│ ├── Account/ /api/account/* - current user profile
│ ├── Auth/ Endpoint groups, role constants, dev seed
│ ├── Blog/ /api/blog/* (public) + /api/admin/blog/* (admin)
│ │ ├── Admin/ Blog CRUD + image upload
│ │ ├── AdminTools/ Wix import, orphan-image cleanup
│ │ ├── BlogImport/ WixBlogClient + BlogImporter
│ │ └── Public/ Public listing/detail
│ ├── Configuration/ GardenOptions (per-garden settings)
│ ├── Data/ AppDbContext, entities, EF migrations
│ ├── Members/ /api/admin/members/* - admin member CRUD + Excel export
│ ├── Misc/ Health ping, /api/config, logout
│ └── Extensions.cs Aspire ServiceDefaults (OTel, health checks, service discovery)
├── frontend/ React + Vite SPA (esproj - built by Aspire)
│ └── src/
│ ├── api/ Typed clients (auth, blog, adminTools, config)
│ ├── pages/ Route components (lazy-loaded via React.lazy)
│ │ ├── home/ Public home page sections + responsive.ts (useMediaQuery)
│ │ ├── blog/ Public blog index + post
│ │ └── admin/ Admin console pages
│ ├── App.tsx Routes + code-split lazy imports
│ ├── AppConfigContext.tsx
│ └── AuthContext.tsx
├── CommonGround.slnx Solution file (.slnx XML format)
└── LICENSE GPL-3.0
| Tool | Version | Notes |
|---|---|---|
| .NET SDK | 10.0+ | Required by Aspire AppHost and the API |
| Node.js | 20.19+ | Vite 8 requires Node 20.19+ or 22.12+ |
| A container runtime | any | Docker Desktop, Podman, or Rancher Desktop - Aspire spins up SQL Server in a container |
| ASP.NET dev cert | - | Run dotnet dev-certs https --trust once if you've never done so |
Windows note: Aspire's container support uses your installed runtime as-is. On macOS/Linux, ensure the Docker daemon is running before starting AppHost.
git clone https://github.com/<your-fork>/CommonGround.git
cd CommonGround
# Restore .NET dependencies
dotnet restore CommonGround.slnx
# Install frontend dependencies
cd frontend && npm install && cd ..The Aspire AppHost is the single entry point. It launches SQL Server (in a container with a persistent volume), the API, and the Vite dev server, and shows them all in the Aspire dashboard.
dotnet run --project CommonGround.AppHostThen open the Aspire dashboard at the URL printed in the console (typically https://localhost:17158). From the dashboard you can click into:
webfrontend- the running React app (Vite dev server)server- the API, with live logs, traces, and metricssql- the SQL Server containercommongroundDb- the database (with a connection string ready to copy)
The API is also reachable directly at https://localhost:7592 if you prefer.
The API applies any pending EF Core migrations on startup (Database.MigrateAsync) in every environment. EF takes an exclusive lock on __EFMigrationsHistory, so concurrent replicas don't race.
When ASPNETCORE_ENVIRONMENT=Development, the API additionally seeds an admin user if one does not exist:
- Email:
admin@local - Password:
Password123! - Role:
Admin
Sign in at the SPA's /login page using those credentials to access /admin. Production does not seed - bootstrap an admin manually (e.g. via a one-off Identity insert).
Configuration is layered in standard ASP.NET Core fashion: appsettings.json → appsettings.{Environment}.json → user-secrets (Development) → environment variables.
| Key | Where | Description | Default |
|---|---|---|---|
Garden:Name |
CommonGround.Server/appsettings.json |
Display name shown in the SPA title bar | Seaford Wetlands Community Garden |
ConnectionStrings:commongroundDb |
injected by Aspire | SQL Server connection string | provided by AppHost |
ASPNETCORE_ENVIRONMENT |
env var | Development enables the dev admin seed and OpenAPI UI; migrations run in every environment |
Development (in launchSettings) |
Email:ApiToken |
secret | Resend API key - required for any outbound email (newsletters, contact form) | empty (sending disabled) |
Email:FromAddress |
appsettings.json |
Verified sender address used as From on outbound mail |
empty |
Email:FromName |
appsettings.json |
Optional display name for the sender | empty |
Contact:RecipientAddress |
appsettings.json |
Inbox that contact form submissions are delivered to | empty (contact form returns 503) |
Contact:TurnstileSiteKey |
appsettings.json |
Cloudflare Turnstile site key - sent to the frontend so it can render the widget | empty (captcha disabled, form still sends) |
Contact:TurnstileSecretKey |
secret | Cloudflare Turnstile secret key - used server-side to verify the captcha token | empty (captcha disabled, form still sends) |
User secrets are bound at the AppHost project (UserSecretsId 9f693f87-7a45-4716-bc0d-7eec835b2bd6). Use them for anything you don't want to commit:
dotnet user-secrets --project CommonGround.AppHost set <Key> <Value>The contact form (rendered by ContactPage on the public site) posts to POST /api/contact. The API sends the submission as an email via Resend to Contact:RecipientAddress, with the submitter's address set as Reply-To so a regular "reply" in your inbox goes back to them.
The form is protected by Cloudflare Turnstile - a free, privacy-friendly CAPTCHA that doesn't depend on Google. Turnstile is optional: if no keys are configured, the form still works, just without bot protection.
Set up Turnstile for production
- Sign in at https://dash.cloudflare.com/?to=/:account/turnstile and click Add site.
- Pick a friendly name and add every domain the form will run on (e.g.
commonground.example.org, plus any staging/preview domains). For local development, also addlocalhost. - Choose a widget mode:
- Managed (recommended) - Cloudflare decides between an invisible check and an interactive challenge per request.
- Non-interactive - runs invisibly but always renders a small "verifying" widget.
- Invisible - fully hidden; failed challenges block submission silently.
- Save. Cloudflare gives you a site key (public, safe to embed) and a secret key (server-side only).
Configure the keys
The site key lives in plain appsettings.json; the secret belongs in user secrets (dev) or an env var / secret store (prod):
# Site key (public - fine to commit to appsettings.{Environment}.json)
# appsettings.Production.json:
# "Contact": {
# "RecipientAddress": "contact@yourgarden.org",
# "TurnstileSiteKey": "0x4AAAAAAA..."
# }
# Secret key (never commit)
dotnet user-secrets --project CommonGround.AppHost set Contact:TurnstileSecretKey "0x4AAAAAAA..."
# Or as env vars in production:
# Contact__TurnstileSecretKey=0x4AAAAAAA...The /api/config endpoint exposes turnstileSiteKey to the SPA, which lazy-loads https://challenges.cloudflare.com/turnstile/v0/api.js and renders the widget. If turnstileSiteKey is null, the SPA skips the widget entirely.
Local development
appsettings.Development.json ships with Cloudflare's published always-pass test keys, so the captcha flow is exercised end-to-end without anyone provisioning a real Turnstile site. Other useful test keys when debugging:
| Site key | Secret key | Behaviour |
|---|---|---|
1x00000000000000000000AA |
1x0000000000000000000000000000000AA |
Always passes (default in dev) |
2x00000000000000000000AB |
2x0000000000000000000000000000000AA |
Always blocks |
3x00000000000000000000FF |
1x0000000000000000000000000000000AA |
Forces an interactive challenge |
To disable the captcha locally (e.g. while iterating on form layout), blank Contact:TurnstileSiteKey and Contact:TurnstileSecretKey in appsettings.Development.json.
Disabling the contact form
Leave Contact:RecipientAddress empty. The endpoint will return 503 Service Unavailable for every submission, which the SPA surfaces as a "temporarily unavailable" error.
If you just need to iterate on the API without the full Aspire stack, you can run the Server project directly - but you'll need to provide a SQL Server connection string yourself, since Aspire's container won't be there.
dotnet run --project CommonGround.Server
# API at https://localhost:7592 / http://localhost:5335The Vite dev server proxies /api/* calls to whatever URL is in SERVER_HTTPS or SERVER_HTTP env vars (set automatically by Aspire). To run standalone, point it at a running API yourself:
cd frontend
SERVER_HTTPS=https://localhost:7592 npm run devcd frontend
npm run dev # Vite dev server with HMR
npm run build # Type-check (tsc -b) then production build to dist/
npm run lint # ESLint over src/
npm run preview # Serve the production build locallyAfter any TypeScript change, run npx tsc -p tsconfig.app.json --noEmit to type-check without producing output. The root tsconfig.json only holds project references, so a bare tsc --noEmit checks nothing - point at tsconfig.app.json (or run tsc -b, which is what npm run build does).
EF Core migrations live in CommonGround.Server/Data/Migrations/.
# Add a new migration
dotnet ef migrations add <MigrationName> --project CommonGround.Server
# Apply pending migrations to a target database manually (the API also applies on startup)
dotnet ef database update --project CommonGround.ServerMigrations are applied automatically on every startup, in all environments. The CLI command above is for when you want to apply ahead of a deploy or against a non-default database.
The API exposes OpenAPI in Development at:
https://localhost:7592/openapi/v1.json
The most relevant route prefixes:
| Prefix | Purpose |
|---|---|
/api/auth/* |
ASP.NET Core Identity API (register, login, etc.) |
/api/account/* |
Current user's profile (GET/PUT /api/account/me) |
/api/blog/* |
Public blog listing, post detail, images |
/api/admin/* |
Admin-only CRUD (members, blog posts, tools) |
/api/config |
Public app config (garden name, Turnstile site key) |
/api/contact |
Public contact form submission (captcha-verified) |
/api/health/ping |
Liveness probe |
/health, /alive |
Aspire health endpoints (Development only) |
There's a small CommonGround.Server.http file you can use with VS Code's REST client extension for ad-hoc requests.
dotnet build CommonGround.slnxThe AppHost bundles the Vite build output into the API's container image so the API can serve the SPA from wwwroot:
// In AppHost.cs
server.PublishWithContainerFiles(webfrontend, "wwwroot");To publish:
dotnet publish CommonGround.AppHost -c ReleaseAspire generates a deployment manifest you can target at any container host.
The Container App is stateless - all durable data lives in the Azure SQL database. A 35-day point-in-time restore window is configured manually per environment (it survives redeploys); coverage beyond 35 days comes from scheduled .bacpac exports, since native long-term retention isn't available on this serverless + auto-pause database. See BACKUP.md for the full strategy, the one-time retention and SQL-server delete-lock setup, and step-by-step restore procedures.
- Endpoint groups. FastEndpoints'
Group<T>andSubGroup<T>are used to compose route prefixes and auth requirements.AdminGroupenforces theAdminrole on every nested endpoint - seeAuth/EndpointGroups.cs. - Aspire ServiceDefaults.
Extensions.cswires OpenTelemetry (ASP.NET Core, HttpClient, Runtime), health checks, and service discovery into every project that callsAddServiceDefaults(). - OTLP exporter. Telemetry is exported via OTLP whenever
OTEL_EXPORTER_OTLP_ENDPOINTis set - Aspire sets this automatically in the dashboard. To send to Azure Monitor or another backend, layer an additional exporter onto the OpenTelemetry builder. - HTML sanitisation. Blog post HTML (whether typed in the editor or imported from Wix) is run through HtmlSanitizer - see
BlogHtmlSanitizer.cs. - Code-split frontend. Route components are lazy-loaded via
React.lazy+Suspense(theHomelanding page stays eager for fast first paint), so heavy dependencies - the tiptap editor and the Leaflet map - download only when their routes are visited. Seefrontend/src/App.tsx. - Responsive home page. The public home page composes inline-styled sections that adapt via a small
useMediaQueryhook (frontend/src/pages/home/responsive.ts) rather than CSS media queries, keeping each section's styling co-located in its component.
| Symptom | Likely cause / fix |
|---|---|
dotnet run --project CommonGround.AppHost hangs at "Starting sql" |
Container runtime isn't running. Start Docker / Podman. |
| Browser shows certificate warning at the dashboard URL | Run dotnet dev-certs https --trust once. |
npm install fails with peer-dep errors |
Use Node 20.19+ (or 22.12+). Older Node versions don't satisfy Vite 8's engines constraint. |
| API responds but the SPA shows "Failed to load configuration" | The Vite dev server can't reach the API. Confirm the AppHost is running and check the dashboard for the webfrontend resource's wired env vars. |
EF migration command fails with No project was found |
Run from the repo root with --project CommonGround.Server. |
| Want to wipe the dev database | Stop AppHost, then docker volume rm the sql-data volume that Aspire created. The next run will re-seed. |
Contributions are welcome. Please:
- Open an issue first to discuss any non-trivial change.
- Branch from
mainand keep PRs focused - one logical change per PR. - Run
dotnet build CommonGround.slnxandnpm run build(infrontend/) before pushing - both must pass. - Match the existing code style: latest C# language features, FastEndpoints groups for routing, no docstrings/comments unless they document a non-obvious "why".
CommonGround is licensed under the GNU General Public License v3.0. See LICENSE for the full text.