RunTracker is a Next.js App Router application for logging runs, tracking pace, and reviewing training history behind authenticated access. The app is hosted on Netlify, stores run data in Neon Postgres through Netlify DB, and uses Neon Auth for account creation, sign-in, session management, and optional OAuth providers.
Live demo: https://runtracker-by-jdr.netlify.app/
- Overview
- Core capabilities
- Technology stack
- Repository layout
- Application architecture
- Page routes
- API routes
- Request lifecycle
- Run data model
- Validation and formatting rules
- Authentication model
- Persistence model
- Database schema and migrations
- Scripts
- Environment variables
- Local development
- Netlify deployment
- Neon configuration
- Security model
- Operational notes
The app is designed around a simple private workflow:
- A user signs in through Neon Auth.
- The client loads that user session with the Neon Auth browser client.
- The dashboard fetches the user-visible run list from a protected API route.
- New runs are submitted to the same protected API and persisted to Postgres.
- The client computes summary cards, pace statistics, and run-history presentation from the returned run list.
The repository intentionally keeps the architecture small:
- UI is built in the Next.js
appdirectory. - Auth is handled with the Neon Auth Next.js SDK.
- Data storage is isolated in one store module.
- Validation and formatting logic are centralized in one utility module.
- Database schema changes are tracked in SQL migration files.
- Authenticated sign-in and sign-up with Neon Auth
- Optional Google OAuth through Neon Auth
- Optional access restriction by allowlisted email addresses
- Run creation, listing, and deletion
- Dashboard summary metrics for total distance, total time, best pace, longest run, and most recent run
- Local development fallback to
data/runs.jsonwhen a database URL is not configured - Netlify deployment with Neon-backed persistence
- Basic API hardening through auth checks, origin checks, rate limiting, and generic error responses
- Next.js
16.2.1 - React
19.2.0 - App Router route handlers
- Tailwind CSS
@neondatabase/authfor Neon Auth server and UI integration@netlify/neonfor Neon Postgres access inside the app and scripts- PostgreSQL / Netlify DB / Neon
- ESLint
The main code lives in src, with SQL migrations in db/migrations and supporting scripts in scripts.
.
|-- data/
| `-- runs.json
|-- db/
| `-- migrations/
| |-- 001_create_runs.sql
| `-- 002_add_auth_and_run_ownership.sql
|-- scripts/
| |-- apply-migrations.mjs
| `-- import-runs.mjs
|-- src/
| |-- app/
| | |-- api/
| | | |-- auth/[...path]/route.js
| | | |-- health/route.js
| | | `-- runs/route.js
| | |-- auth/
| | | |-- [path]/page.js
| | | `-- page.js
| | |-- AuthConfigurationNotice.js
| | |-- AuthPanel.js
| | |-- ClientLayout.js
| | |-- LogRunForm.js
| | |-- NeonAuthProvider.js
| | |-- RunList.js
| | |-- RunTrackerApp.js
| | |-- globals.css
| | |-- layout.js
| | `-- page.js
| `-- lib/
| |-- auth-client.js
| |-- auth.js
| |-- rateLimit.js
| |-- requestLogger.js
| |-- runStore.js
| `-- runs.js
|-- next.config.mjs
`-- package.json
The app is split into five practical layers:
- layout and bootstrapping
- auth
- dashboard UI
- API handlers
- persistence and utilities
src/app/layout.js is the root layout. It:
- imports Neon Auth UI styles
- imports the app global stylesheet
- wraps the entire application in
NeonAuthProvider - defines the site metadata used by Next.js
src/app/page.js is the dashboard entry page. It:
- checks whether Neon Auth is configured
- renders
AuthConfigurationNoticewhen auth env vars are missing - renders
RunTrackerAppwhen auth is configured
src/app/AuthConfigurationNotice.js exists so a missing Netlify or local setup produces a clear on-page configuration message instead of immediately booting a broken session flow.
Auth is centered in src/lib/auth.js. That module:
- creates the Neon Auth server instance with
createNeonAuth - reads
NEON_AUTH_BASE_URL - reads
NEON_AUTH_COOKIE_SECRET - exposes
isNeonAuthConfigured() - implements
requireAuth(request)for protected APIs - optionally restricts access to
ALLOWED_USER_EMAILS - enforces same-origin checks for state-changing requests
src/lib/auth-client.js creates the browser auth client used by client components.
src/app/NeonAuthProvider.js wraps the app in NeonAuthUIProvider, which allows Neon Auth UI components and hooks to work across the app.
src/app/api/auth/[...path]/route.js forwards /api/auth/* requests into the Neon Auth handler. This is the bridge between your Next.js app and the Neon Auth service.
src/app/auth/[path]/page.js renders Neon Auth UI views like:
/auth/sign-in/auth/sign-up/auth/forgot-password
src/app/auth/page.js simply redirects /auth to /auth/sign-in.
src/app/RunTrackerApp.js is the main client-side application. It is responsible for:
- loading the current session through
authClient.useSession() - redirecting users to the sign-in screen when no session exists
- fetching runs from
/api/runs - handling run creation and deletion
- tracking UI loading, auth, authorization, and list error states
- computing dashboard stat cards from fetched runs
The main presentational pieces are:
src/app/AuthPanel.jsUsed when the user is signed out or signed in but blocked by allowlist rules.src/app/ClientLayout.jsProvides the shared shell styling for the dashboard and auth states.src/app/LogRunForm.jsCollects run date, distance, duration, and notes, and submits them to the API.src/app/RunList.jsDisplays the run history and delete controls.
The app uses App Router route handlers instead of pages/api.
src/app/api/runs/route.js handles:
GET /api/runsPOST /api/runsDELETE /api/runs
It performs:
- per-route rate limiting
- auth verification through
requireAuth() - same-origin enforcement for write and delete operations
- request-body parsing
- run input validation
- generic error handling with server-side logging
src/app/api/health/route.js handles:
GET /api/health
It performs:
- rate limiting
- auth verification
- a minimal database health check when
NETLIFY_DATABASE_URLis present
src/lib/runStore.js is the persistence layer. It hides whether data is coming from:
- Postgres through
@netlify/neon - or the local JSON file fallback in
data/runs.json
src/lib/runs.js centralizes:
- run validation
- run normalization
- run sorting
- pace calculations
- dashboard statistics
- date, distance, duration, and pace formatting helpers
src/lib/rateLimit.js implements a small in-memory IP-based rate limiter for API routes.
src/lib/requestLogger.js emits structured security and API error logs to the server console.
The home page is the main dashboard entry point.
Behavior:
- if auth env vars are missing, it renders the configuration notice
- if auth env vars are present, it mounts the dashboard app
Redirects to /auth/sign-in.
Renders Neon Auth’s sign-in UI.
Renders Neon Auth’s sign-up UI.
Handled by the same src/app/auth/[path]/page.js dynamic route and rendered through Neon Auth’s AuthView.
Purpose:
- returns runs visible to the authenticated user
Requirements:
- valid session
- within the read rate limit
Behavior:
- returns runs sorted newest first
- includes legacy rows with
user_id IS NULL - returns
401if no session - returns
403if the signed-in email is not on the allowlist - returns
429when rate limited
Rate limit:
- 60 requests per minute per IP
Purpose:
- creates a new run
Requirements:
- valid session
- same-origin request
- valid JSON body
- within the write rate limit
Accepted body:
{
"date": "2026-03-20",
"distance": 8.5,
"durationMinutes": 46,
"notes": "Optional note"
}Behavior:
- validates input with
validateRunInput() - generates a UUID
- stamps
createdAton the server - writes the row with the authenticated user ID as owner
- returns
201with the saved run payload
Rate limit:
- 20 requests per minute per IP
Purpose:
- deletes a run by ID
Requirements:
- valid session
- same-origin request
- valid JSON body containing an
id - within the delete rate limit
Accepted body:
{
"id": "abc123"
}Behavior:
- deletes only rows visible to the authenticated user
- returns
404if the run is not found - returns the deleted ID on success
Rate limit:
- 20 requests per minute per IP
Purpose:
- returns a small authenticated health payload
Requirements:
- valid session
- within the health-check rate limit
Response includes:
- signed-in account email
status- storage mode
- current timestamp
- database health when a DB URL is configured
Rate limit:
- 10 requests per minute per IP
Unsupported methods return 405 Method Not Allowed and set Allow headers where appropriate.
- The browser opens
/. src/app/page.jschecks whether Neon Auth is configured.- If auth is not configured, the user sees
AuthConfigurationNotice. - If auth is configured,
RunTrackerAppmounts. RunTrackerAppcallsauthClient.useSession().- If there is no session, the user sees
AuthPaneland can navigate to/auth/sign-in. - If a session exists, the client requests
GET /api/runs. - The API validates auth and returns the visible run list.
- The client computes stat cards from the returned runs.
- The user opens
/auth/sign-in. src/app/auth/[path]/page.jsrenders Neon Auth’sAuthView.- The UI sends auth requests to
/api/auth/*. src/app/api/auth/[...path]/route.jsforwards those requests to Neon Auth.- Neon Auth performs sign-in, sign-up, password reset, or provider flow logic.
- On success, session cookies are written.
authClient.useSession()begins returning the authenticated user.
LogRunFormcollects form input.- The client posts JSON to
/api/runs. - The route handler checks rate limits.
- The route handler validates same-origin.
- The route handler validates the user session.
- The route handler validates the input payload.
- The route handler creates a UUID and timestamp.
runStore.createRun()writes the row.- The saved run is returned to the browser.
- The client inserts the new run into local state and recomputes dashboard stats.
RunListsubmitsDELETE /api/runs.- The route handler checks rate limits.
- The route handler validates same-origin.
- The route handler validates the user session.
runStore.deleteRunById()deletes the row if the current user is allowed to see it.- The client removes the deleted run from local state.
The public run shape returned to the client is:
{
"id": "uuid",
"date": "2026-03-20",
"distance": 8.5,
"durationMinutes": 46,
"notes": "Optional note",
"createdAt": "2026-03-20T14:30:00.000Z"
}Internal ownership is added at the persistence layer through userId or user_id, but that owner field is stripped before responses are sent back to the client.
The runs table uses:
id TEXT PRIMARY KEYuser_id TEXTrun_date TEXT NOT NULLdistance_km DOUBLE PRECISION NOT NULLduration_minutes INTEGER NOT NULLnotes TEXTcreated_at TEXT NOT NULL
run_dateis stored as text in ISOYYYY-MM-DDformcreated_atis stored as text, not as a Postgres timestamp columnuser_idis the application-level ownership link between Neon Auth sessions and run rows- legacy rows with
user_id IS NULLremain visible to signed-in users
Validation rules live in src/lib/runs.js.
- date must match
YYYY-MM-DD - distance must be between
0.1and200km - duration must be between
1and1440minutes - notes must be at most
160characters
- distance is rounded to one decimal place
- duration is rounded to the nearest whole minute
- empty notes are omitted from the normalized run value
The app computes:
- total run count
- total distance
- total duration
- average pace
- best pace
- longest distance
- most recent run date
Formatting helpers produce:
- readable run dates like
Mar 20, 2026 - readable durations like
46mor1h 12m - readable distances like
8.5 km - readable pace strings like
5:24 /km
The app uses Neon Auth as the authentication backend and session authority.
- a valid Neon Auth base URL
- a cookie secret for the Next.js integration
- one or more enabled sign-in methods
- trusted domain entries that include the deployed site origin
The current app can work with:
- email sign-up and sign-in
- Google OAuth through Neon Auth
The UI does not implement custom auth screens beyond rendering Neon Auth’s provided views, so provider and email behavior are controlled mostly in the Neon console.
If ALLOWED_USER_EMAILS is set, the app adds an application-level access gate after a successful Neon Auth sign-in.
That means:
- Neon Auth can successfully authenticate the user
- but the app can still return
403if the signed-in email is not on the allowlist
This is useful when sign-up is enabled in Neon but app access should remain restricted.
Run persistence lives in src/lib/runStore.js.
If NETLIFY_DATABASE_URL is present:
- the app uses
@netlify/neon - the app writes and reads runs from Postgres
- the store lazily ensures the
runstable and supporting index exist - the store can seed an empty database from
data/runs.json
If NETLIFY_DATABASE_URL is missing:
- the store falls back to
data/runs.json - the fallback is mainly for local development or bootstrap scenarios
- deployed writes should not rely on this mode
Rows written by the current app receive the signed-in user’s ID as user_id.
Rows with user_id IS NULL remain readable and deletable by signed-in users. This preserves previously imported or pre-auth data, but it also means those legacy rows are not isolated by user until they are explicitly reassigned or rewritten.
Even though the repo includes SQL migrations, the store also performs CREATE TABLE IF NOT EXISTS, ALTER TABLE ... ADD COLUMN IF NOT EXISTS, and index creation on demand. That gives the app a safer startup path when the database exists but schema state is behind.
The repo currently ships with:
db/migrations/001_create_runs.sqldb/migrations/002_add_auth_and_run_ownership.sql
001_create_runs.sql creates:
runsschema_migrations
002_add_auth_and_run_ownership.sql adds:
user_idtoruns- the
runs_user_id_run_date_created_at_idxindex
The migration runner stores applied filenames in the schema_migrations table.
That means:
- migration order is the lexicographic order of the
.sqlfilenames - each migration is applied once
- the applied file name becomes the migration ID
Run:
npm run db:migrateRequirements:
NETLIFY_DATABASE_URLmust be set
Behavior:
- loads all
.sqlfiles fromdb/migrations - skips files already recorded in
schema_migrations - applies each unapplied migration inside a transaction
Run:
npm run db:importOr import a specific file:
npm run db:import -- ./path/to/runs.jsonBehavior:
- requires
NETLIFY_DATABASE_URL - reads a JSON array of runs
- creates the
runstable if needed - inserts runs with
user_id = NULL - ignores duplicate IDs through
ON CONFLICT DO NOTHING
Defined in package.json:
npm run devStarts the local Next.js development server.npm run buildBuilds the production app withnext build --webpack.npm run startStarts the production server locally after a build.npm run lintRuns ESLint against the repository.npm run db:migrateApplies SQL migrations to the configured Netlify DB / Neon database.npm run db:importImports runs from JSON into the configured database.
Used by the Neon Auth Next.js server integration.
Expected value:
- the Neon Auth endpoint for your branch
- not your site URL
Example:
NEON_AUTH_BASE_URL="https://ep-example.neonauth.us-east-1.aws.neon.tech/neondb/auth"Used to sign or protect auth cookies in the app’s Neon Auth integration.
Requirements:
- at least 32 characters
- stable across deployments unless you intentionally want to invalidate sessions
Example:
NEON_AUTH_COOKIE_SECRET="replace-this-with-a-random-secret-at-least-32-characters-long"Used by @netlify/neon for application persistence and migration scripts.
If this variable is not present:
- the app can still fall back to the local JSON file store
- but deployed write behavior should not rely on that path
Comma-separated list of emails allowed to access the app after successful authentication.
Example:
ALLOWED_USER_EMAILS="you@example.com,teammate@example.com"NETLIFY_DATABASE_URL_UNPOOLED
Netlify can provide it, but the current code uses NETLIFY_DATABASE_URL.
NEON_AUTH_BASE_URL="https://your-neon-auth-endpoint.neonauth.us-east-1.aws.neon.tech/neondb/auth"
NEON_AUTH_COOKIE_SECRET="replace-this-with-a-random-secret-at-least-32-characters-long"
NETLIFY_DATABASE_URL="postgresql://..."
ALLOWED_USER_EMAILS="you@example.com"- Node.js
- npm
- a Neon Auth branch URL
- a Neon or Netlify DB connection string if you want database-backed local development
- Install dependencies.
npm install- Create or update
.env.local.
NEON_AUTH_BASE_URL="https://your-neon-auth-endpoint.neonauth.us-east-1.aws.neon.tech/neondb/auth"
NEON_AUTH_COOKIE_SECRET="replace-this-with-a-random-secret-at-least-32-characters-long"
NETLIFY_DATABASE_URL="postgresql://..."
ALLOWED_USER_EMAILS="you@example.com"- Apply migrations if you are using the database.
npm run db:migrate- Start the dev server.
npm run dev- Open http://localhost:3000.
If you want Neon Auth to accept local browser requests, enable localhost in the Neon Auth settings or explicitly allow http://localhost:3000 in trusted domains.
Set these in Netlify before deploying:
NEON_AUTH_BASE_URLNEON_AUTH_COOKIE_SECRETNETLIFY_DATABASE_URL
Optionally set:
ALLOWED_USER_EMAILS
- Connect the GitHub repository to Netlify.
- Add the environment variables listed above.
- Deploy the site.
- Confirm the deployed origin matches one of the trusted domains in Neon Auth.
- Apply database migrations if needed.
- Test sign-up, sign-in, run creation, run listing, and run deletion.
If the app deploys without the required Neon Auth env vars, the homepage renders a configuration notice instead of initializing the session flow. This makes misconfiguration visible without crashing the initial page experience.
The app assumes Neon Auth is configured for the same branch or environment as the database the app uses.
Your Neon Auth trusted domains should include at least:
https://runtracker-by-jdr.netlify.apphttp://localhost:3000for local development if you use it
If you add a custom domain, add that too.
At least one sign-in method must be enabled in Neon Auth. The current app has been used with:
- Email sign-up and sign-in
- Google OAuth
If you use email-based auth, Neon’s shared email provider is sufficient for initial testing. A custom provider is optional and depends on your email deliverability requirements.
The backend includes a small but deliberate security layer.
Protected APIs require a valid Neon Auth session:
/api/runs/api/health
ALLOWED_USER_EMAILS adds a second gate beyond successful authentication.
POST /api/runs and DELETE /api/runs must pass same-origin validation.
Current per-IP limits are:
GET /api/runs: 60 requests per minutePOST /api/runs: 20 requests per minuteDELETE /api/runs: 20 requests per minuteGET /api/health: 10 requests per minute
- client-facing server errors are generic
- internal errors are logged on the server
- security-relevant events are logged with IP, path, method, timestamp, and user agent when available
next.config.mjs applies:
- Content Security Policy
- HSTS in non-development builds
- frame blocking
nosniff- referrer policy
- permissions policy
- cross-origin opener and resource policies
The current security model is intentionally modest and has boundaries:
- rate limiting is in-memory and not shared across all serverless instances
- ownership enforcement is application-level through
user_id, not Postgres row-level security - legacy
NULL user_idrows are not fully isolated per user - the app is still a single-tenant-style private app, not a full multi-tenant platform
If the auth env vars are missing, / renders the configuration notice and the auth API returns configuration errors until the environment is fixed.
Imported or historical rows with user_id IS NULL stay visible to signed-in users. That is intentional for backward compatibility, but it is worth understanding before treating the app as a strict per-user datastore.
The repository includes some packages that are not core to the current runtime path. The current production architecture is the combination of:
- Next.js
- Neon Auth
- Netlify DB / Neon Postgres
- the app code in
src/appandsrc/lib
This app assumes:
- Next.js App Router
- Netlify hosting
- Neon Auth handling authentication
- Neon Postgres handling run persistence
That is the intended architecture documented by this README and reflected in the current codebase.