Terminuler is a full-stack appointment booking system. Customers pick a weekday, choose a free one-hour slot, enter their contact details and get a confirmation email. An administrator signs in to a protected dashboard to see upcoming appointments, search and filter them, and cancel bookings.
I built it as a portfolio project. It has a Next.js frontend, a Go REST API and a PostgreSQL database. Authentication is handled by Clerk and email by Resend. The project is tested with Go unit and integration tests and Playwright end-to-end tests, and it runs in GitHub Actions.
Live demo: terminuler.vercel.app · API: terminuler-api.onrender.com
- Demo and hosting
- Screenshots
- Features
- Tech stack
- Architecture
- Project structure
- Getting started
- Environment variables
- Database
- Authentication and admin dashboard
- Testing
- CI
- Production deployment
- Security
- API
- Technical decisions
- Known limitations
- Project status
- License
| Component | Platform | URL |
|---|---|---|
| Frontend (Next.js) | Vercel | terminuler.vercel.app |
| Backend API (Go) | Render | terminuler-api.onrender.com |
| Database | PostgreSQL, used only by the API | private |
| Admin authentication | Clerk (development instance) | – |
| Email delivery | Resend | – |
The public deployment is a demo. The admin dashboard only lets in one configured Clerk user, so visitors can try the booking flow but not the admin area. The API runs on Render's free tier, so after a period without traffic the first request can take up to about a minute while the service wakes up.
These screenshots show the public booking flow. They were taken before the Admin link was added to the page header, and the admin dashboard is not pictured yet.
| Choose a date | Available times |
|---|---|
![]() |
![]() |
| Customer details | Confirmation |
|---|---|
![]() |
![]() |
On small screens the time grid switches from four columns to two. When a slot is selected, the contact form scrolls into view.
- Pick a date. The date picker blocks past dates.
- See the free one-hour slots, Monday to Friday, 08:00–16:00. That is eight slots per day, the last one starting at 15:00. Booked slots and slots that have already started are hidden. Bookings can be made up to 60 days ahead.
- Enter name, phone and email, then confirm. The confirmation screen shows the date, time and email address.
- Double booking is handled. If someone else takes the slot first, the API answers
409. The page explains this, reloads the availability and keeps the details the customer already typed. - Loading, error and success states are shown for every step. Validation errors show the API's message. A rate-limited booking (
429) asks the customer to wait. Other errors show a generic message. - Accessible, keyboard-friendly controls (native inputs, labelled fields,
aria-pressedslots, focus management) and a responsive layout.
- Protected by Clerk sign-in. Only the user configured in
ADMIN_CLERK_USER_IDcan load data. - Lists the appointments from today onwards, ordered by date and time, with their status (Confirmed or Cancelled).
- Search by customer name, email or phone, filter by status, and clear the filters.
- Cancel a confirmed appointment after a confirmation dialog. The customer gets a cancellation email and the slot becomes bookable again.
- Cancelled appointments stay in the list (soft cancellation), so the dashboard keeps a history of what was cancelled.
- Table layout on desktop, stacked cards on mobile, plus a Logout button.
- Clear messages for an expired session (
401) and for a signed-in user who is not the admin (403).
- A confirmation email is sent after each booking, and a cancellation email after each admin cancellation.
- With
EMAIL_PROVIDER=log, emails are written to the API log instead of being sent. Local development and the E2E tests use this mode.
- Go REST API on the standard library
net/httprouter, layered as handlers → services → repositories. - All business rules are enforced on the server. The frontend only reflects them.
- PostgreSQL with embedded migrations, a partial unique index against double booking, and soft cancellation.
- Per-client rate limiting on appointment creation.
/healthchecks that the database answers, request access logs, security headers, explicit server timeouts and graceful shutdown.
| Layer | Technology | Version (from the project files) |
|---|---|---|
| Frontend | Next.js (App Router) | 16.3.8 |
| React | 19.2.8 | |
| TypeScript | 5.9 | |
| Tailwind CSS | 4.3 (via @tailwindcss/postcss) |
|
Clerk (@clerk/nextjs) |
7.9 | |
ESLint (eslint-config-next) |
9 | |
| Backend | Go, standard library net/http |
1.26.2 (go.mod) |
Clerk Go SDK (clerk-sdk-go/v2) |
v2.7.0 | |
pgx (through database/sql) |
v5.11.0 | |
| golang-migrate | v4.20.1 | |
| resend-go | v4.7.0 | |
| godotenv | v1.5.1 | |
| Database | PostgreSQL | 18 (Docker image and CI) |
| Testing | Go testing, Playwright (Chromium) |
Playwright 1.63 |
| Infrastructure | Docker, Docker Compose, GitHub Actions, Vercel, Render | Node 22 in CI |
Browser
│ same-origin requests only: /api/appointments/*, /api/admin/*
▼
Next.js on Vercel
│ pages + route handlers (the API proxy)
│ adds Clerk session token (admin) or X-Real-IP + X-Proxy-Secret (booking)
▼
Go API on Render
├── PostgreSQL appointments (business data)
├── Clerk verifies admin session tokens
└── Resend confirmation and cancellation emails
- The browser never calls the Go API directly. It calls Next.js route handlers on its own origin, and they forward the request to the API at
API_URL, which is a server-only variable. The API therefore needs no CORS configuration. - Secrets stay on the server.
API_PROXY_SECRET,CLERK_SECRET_KEYand the API URL are only read by server code. The only value exposed to the browser is the Clerk publishable key (NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY), which is public by design. - For admin requests, the route handler reads the Clerk session and forwards its token as
Authorization: Bearer …. The Go API verifies the token and checks the user ID itself. - For bookings, the route handler forwards the client IP in
X-Real-IPtogether withX-Proxy-Secret, so the API's rate limiter can tell customers apart. - The Go API owns the business rules: slot validation, availability, double-booking protection, cancellation and email.
- PostgreSQL stores the appointments. It is only reachable by the API.
The proxy is not the security boundary: the Go API is publicly reachable and does all validation, authorization and rate limiting itself.
Naming note:
frontend/src/proxy.tsis the Next.js 16 proxy file (formerlymiddleware.ts). It only runsclerkMiddleware()so the session is available to pages and route handlers. The API proxy is the set of route handlers underfrontend/src/app/api.
terminuler/
├── .github/workflows/ci.yml # CI: Go checks/tests, frontend checks, Playwright E2E
├── docker-compose.yml # Local PostgreSQL 18 (host port 5433)
├── .env.example # Template for the repository-root .env
├── docs/images/ # README screenshots
├── LICENSE
│
├── backend/ # Go API (module github.com/pitercoding/terminuler)
│ ├── Dockerfile # Multi-stage image with the api and migrate binaries
│ ├── cmd/
│ │ ├── api/ # HTTP server: wiring, timeouts, graceful shutdown
│ │ ├── migrate/ # Applies pending migrations and exits
│ │ └── e2edb/ # Creates, migrates and empties the *_test database for E2E
│ └── internal/
│ ├── auth/ # Clerk token verification, admin-only middleware
│ ├── config/ # Environment variables, defaults and validation
│ ├── database/ # Connection pool, migration runner
│ │ └── migrations/ # Embedded SQL migrations
│ ├── email/ # Sender interface, Resend and log implementations
│ ├── handlers/ # HTTP handlers, JSON decoding, error mapping
│ ├── middleware/ # Security headers, access log
│ ├── models/ # Appointment model and status
│ ├── ratelimit/ # Sliding-window limiter, client IP resolver
│ ├── repositories/ # SQL queries
│ ├── routes/ # Route registration
│ └── services/ # Business rules
│
└── frontend/ # Next.js application
├── next.config.ts # Security headers, x-powered-by disabled
├── playwright.config.ts # Starts the API and Next.js for E2E runs
├── src/
│ ├── proxy.ts # Clerk middleware (Next.js 16 "proxy" file)
│ ├── app/
│ │ ├── page.tsx # Public booking flow
│ │ ├── admin/ # Admin dashboard (layout requires a session)
│ │ └── api/ # Route handlers proxying to the Go API
│ ├── components/ # DateSelector, TimeSlotGrid, AppointmentForm, ...
│ ├── lib/ # API proxy helpers, date formatting, scrolling
│ └── services/ # Typed fetch client for the route handlers
└── tests/e2e/ # Playwright specs
| Tool | Version / notes |
|---|---|
| Git | any recent version |
| Go | 1.26.2 or newer (backend/go.mod) |
| Node.js + npm | Node 22 (the version used in CI) |
| Docker with Compose | Runs PostgreSQL. You don't need a local PostgreSQL installation. |
| Clerk account | Free. A development instance provides the keys the frontend needs to start. |
| Resend account | Optional. Not needed if you use EMAIL_PROVIDER=log. |
git clone https://github.com/pitercoding/terminuler.git
cd terminulerThe project uses two env files, both git-ignored:
| File | Read by |
|---|---|
.env (repository root) |
Docker Compose, the Go API and the Playwright config |
frontend/.env.local |
Next.js |
Create the root .env from the template:
cp .env.example .env # PowerShell: Copy-Item .env.example .envThen edit .env:
- Replace
change_mein bothPOSTGRES_PASSWORDandDATABASE_URLwith the same password. - Set
EMAIL_PROVIDER=logto run without Resend. Emails will then appear in the API log. - Set
CLERK_SECRET_KEYto the secret key of your Clerk development instance (Clerk Dashboard → API keys). - Leave
ADMIN_CLERK_USER_ID=change_mefor now. You will set it in step 6.
Create frontend/.env.local:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
# Optional locally: defaults to http://localhost:8080 outside production
API_URL=http://localhost:8080CLERK_SECRET_KEY must be the same key in both files. Next.js does not read the root .env.
docker compose up -dThis starts the postgres service (container terminuler-postgres, image postgres:18) on localhost:5433, so it doesn't collide with a local PostgreSQL on 5432. Data is stored in the terminuler-postgres-data volume. The database, user and password come from POSTGRES_DB, POSTGRES_USER and POSTGRES_PASSWORD in .env.
cd backend
go run ./cmd/migrate
go run ./cmd/apigo run ./cmd/api does not run migrations, so run cmd/migrate first and again after pulling new migrations. Only the Docker image runs migrations automatically. The API loads the root .env (it looks in ./.env, then ../.env). It refuses to start if a required variable is missing or invalid.
curl http://localhost:8080/health
# {"status":"ok"}In a second terminal:
cd frontend
npm ci
npm run dev- Open
http://localhost:3000/admin(or click Admin). You are redirected to Clerk's hosted sign-in page for your development instance. Sign up or sign in. - Until the admin user is configured, the dashboard says your account does not have access (
403). - Copy your user ID (
user_...) from Clerk Dashboard → Users, put it inADMIN_CLERK_USER_IDin the root.env, and restart the API. - Reload
/admin. Bookings you make on the home page now appear there.
| Service | URL |
|---|---|
| Frontend (Next.js dev server) | http://localhost:3000 |
| Admin dashboard | http://localhost:3000/admin |
| Go API | http://localhost:8080 |
| PostgreSQL | localhost:5433 |
| E2E API / web (started by Playwright) | http://localhost:8081 / http://localhost:3001 |
Never commit real values. .env and .env.* files are git-ignored (only .env.example is committed), and backend/.dockerignore keeps them out of the Docker build context.
| Variable | Required | Default | Purpose |
|---|---|---|---|
DATABASE_URL |
Yes | – | PostgreSQL connection string |
CLERK_SECRET_KEY |
Yes | – | Verifies admin session tokens. Must be from the same Clerk instance as the frontend keys. |
ADMIN_CLERK_USER_ID |
Yes | – | Clerk user ID (user_...) of the only user allowed on /admin endpoints |
PORT |
No | 8080 |
HTTP port (Render sets it) |
APP_TIMEZONE |
No | UTC |
IANA timezone for business hours and "today", e.g. Europe/Berlin |
APPOINTMENT_RATE_LIMIT |
No | 5 |
Bookings allowed per client per minute |
TRUSTED_PROXIES |
No | empty | Comma-separated CIDRs/IPs allowed to send X-Real-IP (the template sets localhost) |
API_PROXY_SECRET |
No (needed in production) | empty | Shared secret sent by the Next.js proxy in X-Proxy-Secret. At least 32 characters, e.g. openssl rand -hex 32. |
EMAIL_PROVIDER |
No | resend |
resend sends emails, log only logs them |
RESEND_API_KEY |
When EMAIL_PROVIDER=resend |
– | Resend API key |
RESEND_FROM_EMAIL |
No | onboarding@resend.dev |
Sender address (see Email) |
| Variable | Purpose |
|---|---|
POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD |
Initialize the local PostgreSQL container. They must match DATABASE_URL. |
TEST_DATABASE_URL |
Go integration tests. Must be exported in the shell, because the tests do not read .env. |
E2E_DATABASE_URL |
Optional. Database for Playwright. Defaults to DATABASE_URL with the database renamed to terminuler_test. The name must end in _test. |
| Variable | Exposure | Purpose |
|---|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY |
Public (bundled into the browser) | Clerk publishable key |
CLERK_SECRET_KEY |
Server-only | Clerk secret key. Same instance, and the same value as the API's. |
API_URL |
Server-only | Base URL of the Go API. Defaults to http://localhost:8080 outside production. Required in production, where a missing value makes the route handlers answer 503. |
API_PROXY_SECRET |
Server-only | Must equal the API's API_PROXY_SECRET. Never prefix it with NEXT_PUBLIC_. |
Shared between Vercel and Render: CLERK_SECRET_KEY (same Clerk instance) and API_PROXY_SECRET (same value).
The API uses a single appointments table, created and changed by two migrations in backend/internal/database/migrations:
000001_create_appointments: the date, start and end times, customer name, phone and email, andcreated_at.000002_add_appointment_status: addsstatus(confirmedorcancelled) andcancelled_at, with check constraints that keep them consistent.
Soft cancellation. Cancelling an appointment sets status = 'cancelled' and cancelled_at. The row is never deleted.
Double-booking protection. A partial unique index on (appointment_date, start_time) WHERE status = 'confirmed' allows at most one confirmed appointment per slot. A cancelled appointment therefore frees its slot for a new booking. The API does not check-then-insert, which would race. It inserts directly, and PostgreSQL rejects a concurrent duplicate with a unique violation, which the API maps to 409 Conflict. Cancelling is a single conditional UPDATE, so cancelling the same appointment twice succeeds only once.
Migrations are embedded in the Go binaries and applied with golang-migrate, which records the applied version and takes a database lock. Running migrations repeatedly or concurrently is therefore safe.
| How | When |
|---|---|
go run ./cmd/migrate (from backend/) |
Locally, before starting the API |
./migrate && exec ./api in the Docker image |
On every container start (production) |
go run ./cmd/e2edb |
Before each E2E run. It also empties the test database. |
Clerk handles sign-in. The app has no user table of its own, and there is a single admin, identified by Clerk user ID.
Protection is checked at three levels:
- Admin page.
frontend/src/app/admin/layout.tsxcallsauth.protect(), so a signed-out visitor is redirected to Clerk's sign-in page before anything is rendered. - Admin route handlers.
/api/admin/*answer401when there is no Clerk session. Otherwise they forward the session token to the API. - Go API.
/admin/*routes verify the token with the Clerk Go SDK (401if it is missing or invalid) and compare its subject withADMIN_CLERK_USER_ID(403for any other user).
The Go API is the authority: any signed-in Clerk user can open the page, but only the configured admin can read or cancel appointments.
Logout uses Clerk's SignOutButton and returns to /admin, which shows the sign-in page again.
Emails are sent through an email.Sender interface. EMAIL_PROVIDER selects the implementation at startup.
EMAIL_PROVIDER |
Behavior |
|---|---|
resend (default) |
Sends "Appointment confirmation" and "Appointment cancelled" HTML emails through the Resend API. Requires RESEND_API_KEY, otherwise the API does not start. |
log |
Writes the email details to the API log and never fails. Used in local development and E2E tests. |
- Emails are sent after the booking or cancellation is stored. A send failure is logged and the request still succeeds, so a flaky email provider cannot make a stored booking look failed. Emails are not retried.
- Each Resend request has a 10-second timeout, shorter than the server's 20-second write timeout.
- The customer name is HTML-escaped before it goes into the email body.
- Sender address. Without
RESEND_FROM_EMAIL, the API uses Resend's testing senderonboarding@resend.dev. No custom domain is configured in this project. Resend only delivers mail from that testing sender to the Resend account owner's own address. Sending to any customer requires a sender on a domain verified in Resend.
cd backend
go test ./... # unit tests
go vet ./... # static checks
go vet -tags=integration ./...
gofmt -l . # lists unformatted files (CI fails if any)The unit tests cover configuration parsing, booking rules (business hours, horizon, past slots, timezones, phone/email validation), handler status codes and JSON handling, the JSON content-type check, routes and methods, admin authorization (401/403), the rate limiter and client IP resolution, security headers and access logging, the health check, the log email sender and the E2E database guard. The clock is injected, so time-dependent rules are deterministic.
Integration tests run against a real PostgreSQL behind the integration build tag. Each run creates its own schema, applies the migrations and drops the schema at the end. The tests cover the repository and the service → repository flow, including concurrent bookings of the same slot and soft cancellation. TEST_DATABASE_URL must be set in the shell:
# bash
TEST_DATABASE_URL="postgres://terminuler:<password>@localhost:5433/terminuler" \
go test -count=1 -tags=integration ./...# PowerShell
$env:TEST_DATABASE_URL = "postgres://terminuler:<password>@localhost:5433/terminuler"
go test -count=1 -tags=integration ./...CI runs both suites with -race. The race detector needs cgo and a C compiler, so on Windows without a 64-bit GCC toolchain, run the tests without -race.
cd frontend
npx next typegen # generates next-env.d.ts and route types (not committed)
npx tsc --noEmit # type check
npm run lint # ESLint
npm run build # production build (needs NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY)cd frontend
npx playwright install chromium # first time only
npm run test:e2e # headless
npm run test:e2e:ui # Playwright UI modeRequirements: PostgreSQL running (docker compose up -d), Go installed, and the Clerk keys in frontend/.env.local or the environment.
playwright.config.ts starts its own servers, so the tests can run while the dev servers are up:
- API on port 8081.
go run ./cmd/e2edb && go run ./cmd/api, withEMAIL_PROVIDER=logand the rate limit raised to 1000/min.e2edbcreates, migrates and emptiesterminuler_test, and refuses any database whose name does not end in_test. - Frontend on port 3001. A production build (
npm run build && npm run start).
| Spec | What it covers |
|---|---|
booking.spec.ts |
A full booking from date selection to the confirmation screen. The booked slot is no longer offered afterwards. |
booking-conflict.spec.ts |
Two browsers pick the same slot. The second booking gets 409, sees the conflict message, keeps its details and books another slot. |
security.spec.ts |
Security headers and no x-powered-by; /admin redirects without a session; /api/admin/* return 401 without a session; a non-JSON booking gets 415; invalid JSON gets the API's 400. |
api-proxy.spec.ts |
Proxy helper behavior: status and Retry-After forwarding, no-store, 502/504 when the API is unreachable or slow, 204 pass-through, the JSON content-type check. |
On failure, traces and screenshots are kept in frontend/test-results/.
.github/workflows/ci.yml runs on every push to main and every pull request targeting main. A newer push to the same ref cancels the run in progress, and the workflow has read-only repository permissions.
| Job | Steps |
|---|---|
| Backend (Go) | gofmt -l check, go vet (with and without the integration tag), go test -race ./..., go test -race -tags=integration ./... against a PostgreSQL 18 service container |
| Frontend (Next.js) | Node 22, npm ci, next typegen + tsc --noEmit, npm run lint, npm run build |
| E2E (Playwright) | Runs after both jobs pass. Installs Chromium, checks the format of the Clerk keys, then runs npm run test:e2e against a PostgreSQL 18 service container. Uploads frontend/test-results/ on failure (kept 7 days). |
The workflow needs two repository secrets: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY. The E2E tests do not sign in, and the API they start uses placeholder Clerk values. Deployment is not part of the workflow.
The API is deployed as a container built from backend/Dockerfile. The multi-stage build produces two static binaries (api and migrate) on alpine:3.22, which runs as a non-root user. The start command is:
./migrate && exec ./apiMigrations run on every start because Render's free tier has no pre-deploy step. exec makes the API PID 1, so it receives SIGTERM and shuts down gracefully.
Environment variables set in Render:
DATABASE_URL: the PostgreSQL connection stringCLERK_SECRET_KEY,ADMIN_CLERK_USER_IDAPI_PROXY_SECRET: the same value as on VercelAPP_TIMEZONEEMAIL_PROVIDER=resend,RESEND_API_KEY, optionallyRESEND_FROM_EMAIL- Optionally
APPOINTMENT_RATE_LIMIT.PORTis provided by Render.
Use /health as the health check path. It returns 200 {"status":"ok"} when the database answers a ping and 503 {"status":"unavailable"} otherwise.
TRUSTED_PROXIES is not useful on Render, because Vercel's outgoing IPs are not fixed. API_PROXY_SECRET is what lets the API trust the client IP sent by the proxy. Without it, all bookings through the proxy share one rate limit, and the API logs a warning at startup.
Vercel builds the Next.js app from frontend/ (next build). Environment variables:
API_URL=https://terminuler-api.onrender.comNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,CLERK_SECRET_KEYAPI_PROXY_SECRET: the same value as on Render
The public deployment uses a Clerk development instance (pk_test_ / sk_test_ keys) and its hosted sign-in pages. That is a deliberate choice for a portfolio demo with a single admin. Development instances have usage limits and show Clerk's development branding. A real production deployment would use a Clerk production instance on its own domain. All three Clerk values (publishable key, secret key, admin user ID) must come from the same instance, because a token from another instance is rejected with 401.
Emails go through the Resend API with RESEND_API_KEY. See Email for the sender-address limitation: without a verified domain, only the Resend account owner receives emails. Bookings and cancellations still succeed when an email cannot be delivered.
These measures are implemented. They are a reasonable baseline for a small public demo, not a complete security program.
- Authentication and authorization. Clerk sessions are verified by the Go API on every admin request, and only the configured admin user ID is allowed (
401/403). The admin page also requires a session on the server. - Server-side secrets. The Clerk secret key, API URL and proxy secret are only available to server code. Env files are git-ignored and excluded from the Docker build context.
- API proxy secret.
X-Real-IPis only trusted when the request comes fromTRUSTED_PROXIESor carriesX-Proxy-Secret, which is compared in constant time. The client IP cannot be spoofed to bypass the rate limit. - Rate limiting. A sliding window per client on
POST /appointments(default 5/min) returns429withRetry-After. IPv6 clients are grouped by/64. - JSON Content-Type enforcement. Bookings must be
application/json(415otherwise), both in the proxy and in the API, so another site cannot submit bookings through a visitor's browser without a CORS preflight. - Strict input handling. 1 MiB body limit, unknown JSON fields rejected, server-side validation of every field (phone format, plain email address, control characters, lengths) and of every business rule.
- Parameterized SQL for all queries.
- Database-level integrity. A partial unique index prevents double booking. Cancellation is a single conditional update.
- Security headers. Next.js sends CSP
frame-ancestors 'none',X-Frame-Options: DENY,nosniff,Referrer-PolicyandPermissions-Policy, and hidesx-powered-by. The API sends a restrictive CSP,nosniff,no-referrerandCache-Control: no-store. - Controlled errors. Unexpected errors return a generic
internal server error, and details only go to the server log. The access log records method, path, status and duration, but no query strings or headers. - Runtime hardening. Explicit HTTP timeouts, a capped database connection pool, graceful shutdown, and a non-root container.
Base URLs: https://terminuler-api.onrender.com (production) · http://localhost:8080 (local).
Errors use one shape: {"error": "message"}. A wrong method gets 405 with an Allow header.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/health |
– | Liveness and database check: 200 {"status":"ok"} or 503 |
GET |
/appointments/availability?date=YYYY-MM-DD |
– | Free slots for a date: {"date": "...", "available_slots": [{"start_time": "08:00", "end_time": "09:00"}, ...]}. Empty for weekends, past days and dates beyond 60 days. |
POST |
/appointments |
– (rate limited) | Creates a booking. Returns 201 with the appointment. Errors: 400 validation, 409 slot taken, 413 body too large, 415 not JSON, 429 rate limited. |
GET |
/admin/appointments |
Clerk token, admin | Appointments from today onwards (confirmed and cancelled), ordered by date and time |
DELETE |
/admin/appointments/{id} |
Clerk token, admin | Soft-cancels a confirmed appointment and emails the customer. Returns 204, 400 for an invalid id, or 404 if the appointment is missing or already cancelled. |
Example booking request:
curl -X POST http://localhost:8080/appointments \
-H "Content-Type: application/json" \
-d '{
"appointment_date": "2026-10-05",
"start_time": "13:00",
"end_time": "14:00",
"customer_name": "Jane Doe",
"customer_phone": "+49 151 23456789",
"customer_email": "jane@example.com"
}'Appointment responses include id, appointment_date (plain YYYY-MM-DD), start_time, end_time, the customer fields, status, created_at and cancelled_at (null unless cancelled).
| Route | Forwards to |
|---|---|
GET /api/appointments/availability?date=… |
GET /appointments/availability |
POST /api/appointments |
POST /appointments, adding X-Real-IP and X-Proxy-Secret |
GET /api/admin/appointments |
GET /admin/appointments with the Clerk session token |
DELETE /api/admin/appointments/{id} |
DELETE /admin/appointments/{id} with the Clerk session token |
The route handlers return the API's status and JSON body, including Retry-After, and mark every response no-store. If the API cannot be reached they answer 502. If it doesn't answer within 60 seconds they answer 504. If API_URL is missing in production they answer 503.
- Go with the standard library. Go 1.22+ routing patterns give method matching,
405andHEADsupport, which is enough for five endpoints without a framework. Middlewares are plainfunc(http.Handler) http.Handler. - Next.js. One project serves the React UI and the server-side route handlers that keep secrets and the API URL away from the browser.
- PostgreSQL owns booking integrity. A partial unique index is safer than an application-level availability check, which can race. Concurrent integration tests and a two-browser E2E test verify it.
- API proxy. Same-origin requests mean no CORS. The proxy also attaches the Clerk token for admin calls and the client IP plus proxy secret for rate limiting.
- Soft cancellation. Keeping cancelled rows preserves history for the admin and makes cancellation idempotent, while the partial index still frees the slot.
- Clerk. Sign-in, sessions and token verification come from a dedicated service instead of custom password handling. With a single admin, authorization is a user ID comparison in the API.
- Playwright. Tests run against the real stack (browser → Next.js → Go → PostgreSQL) with nothing mocked, using a production build and a separate, self-guarding test database.
- Business rules in a service with an injected clock. Rules such as "no past slots" and "today in
APP_TIMEZONE" are tested deterministically, including timezone edge cases. Dates travel as plainYYYY-MM-DDto avoid off-by-one-day bugs in browsers. - No Redis, queue or ORM. The API runs as a single instance with one table. An in-memory rate limiter and synchronous, best-effort emails are enough. The
Senderinterface, the repository interface and the rate-limit middleware are clear points to extend if that changes.
- One admin, configured by Clerk user ID. There are no roles or multiple staff members.
- Fixed schedule. Hours, weekdays and the 60-day horizon are constants in code. Holidays and breaks are not supported.
- The admin dashboard lists appointments from today onwards. Past appointments are not shown, and appointments cannot be rescheduled.
- Rate limiting is in memory, per process. It resets on restart and is not shared between instances.
- Emails are not retried or tracked. Without a verified Resend domain, only the account owner receives them.
- The public demo uses a Clerk development instance, and the API runs on Render's free tier with cold starts.
- UI is English only, with a light theme only.
Portfolio project, V2 completed. The application is functional, tested and deployed. V1 delivered the public booking flow. V2 added Clerk authentication, the admin dashboard, cancellation emails and soft cancellation, plus production hardening (proxy secret, content-type enforcement, security headers, database health check, access logs). It is not a commercial product.
MIT © 2026 Piter Gomes





