Get notified the moment your favourite Nike products go on sale or restock.
- Motivation
- Quick Start
- Usage
- Overview
- Architecture
- How the Scraper Works
- Database Schema
- Project Structure
- Tech Stack
- Freemium Tier Design
- API Reference
- Environment Variables
- Local Development
- Build Phases
- Contributing
Sneaker drops and Nike sales sell out in minutes. Manually refreshing product pages is tedious and unreliable. KickAlert was built to automate that — scraping Nike every 5 minutes and sending an email the moment a price drops or a sold-out product comes back in stock, so you never miss a drop again.
Prerequisites: Go 1.22+, Node.js 20+, PostgreSQL, an Apify account (for the Nike scraper actor), and an SMTP server.
# 1. Clone the repo
git clone https://github.com/your-username/kick-alert.git
cd kick-alert
# 2. Copy the example env file and fill in your values
cp .env.example .env
# 3. Run database migrations
make db/migrations/up
# 4. Start the API (background scheduler included)
make run/apiThe API will be available at http://localhost:4000.
# Register
curl -X POST http://localhost:4000/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Jane Doe","email":"jane@example.com","password":"pa$$word123"}'
# Activate (token is sent to your email)
curl -X PUT http://localhost:4000/v1/activation \
-H "Content-Type: application/json" \
-d '{"token":"<activation_token>"}'# Log in — returns access token in JSON, sets refresh_token as an httpOnly cookie
curl -X POST http://localhost:4000/v1/login \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{"email":"jane@example.com","password":"pa$$word123"}'
# Add a Nike product to the catalog by URL
curl -X POST http://localhost:4000/v1/products \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"product_url":"https://www.nike.com/t/air-max-90-shoes/..."}'
# Add the product to your watchlist
curl -X POST http://localhost:4000/v1/watchlist \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"product_id":"<product_uuid>","alert_sale":true,"alert_restock":true}'From here the background scheduler takes over — you will receive an email whenever a price drop or restock is detected.
KickAlert is a SaaS that monitors Nike for price drops and restocks — then notifies subscribed users via email. Supports footwear, apparel, and equipment.
Core user flow:
- User signs up and activates their account via email
- User submits a Nike product URL; the API scrapes it via Apify and adds it to the catalog
- User adds the product to their watchlist with optional alert preferences
- A background scheduler re-scrapes every 5 minutes
- When a price drop or restock is detected, an email notification is dispatched and logged
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ React Router v7 + Vite (SPA, no SSR) │
│ Auth · Dashboard · Watchlist · Notification Feed │
└──────────────────────────┬──────────────────────────────────────┘
│ HTTPS / REST
┌──────────────────────────▼──────────────────────────────────────┐
│ API SERVICE (Go) │
│ /auth /products /watchlist /notifications │
│ JWT · Rate Limiting │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Background Goroutines (same process) │ │
│ │ │ │
│ │ robfig/cron → Scheduler → Apify Scraper → Notifier │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────────┘
│ reads / writes
▼
┌────────────────────────┐
│ PostgreSQL │
│ │
│ users │
│ tokens │
│ products │
│ watchlist │
│ price_history │
│ notifications │
└────────────────────────┘
Everything runs in a single Go binary. The scheduler, scraper, and notifier are background goroutines — no message broker needed.
The scraper runs as a goroutine launched on app startup using robfig/cron.
Every 5 minutes:
1. Query DB for all products
2. For each product: call Apify Nike actor using the product's external_id
3. Compare fetched price/stock against current DB row
4. If price changed:
a. Insert row into price_history
b. Update products.current_price, in_stock, last_scraped_at
c. Query watchlist for users watching this product
d. Filter by user alert preferences (alert_sale, alert_restock)
e. Send email notification via SMTP
f. Insert row into notifications table
Scrape interval: every 5 minutes for all tiers. Tier differences are enforced at the watchlist level.
-- Core users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email citext UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT,
activated BOOLEAN DEFAULT false,
notify_email BOOLEAN DEFAULT true,
notify_push BOOLEAN DEFAULT false,
tier TEXT DEFAULT 'free', -- 'free' | 'pro'
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Auth tokens (activation + refresh)
CREATE TABLE tokens (
hash TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expiry TIMESTAMPTZ NOT NULL,
scope TEXT NOT NULL -- 'activation' | 'refresh'
);
-- Normalised Nike product catalog (shared across all users)
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
sku TEXT NOT NULL,
external_id TEXT UNIQUE NOT NULL, -- Nike cloudProductId
category TEXT NOT NULL DEFAULT 'FOOTWEAR',
url TEXT NOT NULL,
image_url TEXT,
current_price TEXT,
currency TEXT DEFAULT 'USD',
in_stock BOOLEAN,
last_scraped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- User watch preferences per product
CREATE TABLE watchlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
alert_sale BOOLEAN DEFAULT true,
alert_restock BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, product_id)
);
-- Full price + stock history (append-only)
CREATE TABLE price_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
price TEXT,
in_stock BOOLEAN,
scraped_at TIMESTAMPTZ DEFAULT now()
);
-- Sent notifications log
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
watchlist_id UUID NOT NULL REFERENCES watchlist(id) ON DELETE CASCADE,
type notification_type NOT NULL, -- 'PRICE_DROP' | 'RESTOCK'
old_price TEXT,
new_price TEXT,
read BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
);kick-alert/
├── cmd/api/
│ ├── main.go # Entry point, config loading, starts server
│ ├── server.go # HTTP server with graceful shutdown
│ ├── routes.go # Gin router and middleware setup
│ ├── middleware.go # JWT auth, CORS, per-IP rate limiting
│ ├── errors.go # Unified error response helpers
│ ├── healthcheck.go # GET /v1/healthcheck
│ ├── auth.go # Login, token refresh, logout, activation handlers
│ ├── users.go # Register handler
│ ├── products.go # Product catalog handlers + Apify scrape on add
│ ├── watchlist.go # Watchlist CRUD handlers
│ ├── notifications.go # Notification retrieval + read handlers
│ ├── scheduler.go # Background scraper + notifier goroutine
│ └── apify.go # Apify Nike actor client
├── internal/
│ ├── auth/
│ │ └── auth.go # JWT creation/validation, Argon2id hashing, token generation
│ ├── database/ # sqlc-generated type-safe query code
│ │ ├── db.go
│ │ ├── models.go
│ │ ├── users.sql.go
│ │ ├── tokens.sql.go
│ │ ├── products.sql.go
│ │ ├── watchlist.sql.go
│ │ ├── notifications.sql.go
│ │ └── price_history.sql.go
│ └── mailer/
│ ├── mailer.go # SMTP email client with retry logic
│ └── templates/
│ ├── user_welcome.tmpl
│ └── price_alert.tmpl
├── frontend/
│ ├── app/
│ │ ├── routes/ # File-based routes (React Router v7)
│ │ │ ├── home.tsx # Landing page
│ │ │ ├── _auth.tsx # Auth layout (login/register/activate)
│ │ │ ├── _auth.login.tsx
│ │ │ ├── _auth.register.tsx
│ │ │ ├── _auth.activate.tsx
│ │ │ ├── _protected.tsx # Protected layout (requires auth)
│ │ │ ├── _protected.dashboard.tsx # Watchlist dashboard
│ │ │ ├── _protected.notifications.tsx
│ │ │ ├── _protected.products.$id.tsx # Product detail + price chart
│ │ │ └── _protected.settings.tsx
│ │ ├── components/
│ │ │ ├── dashboard/ # Dashboard-specific components
│ │ │ └── ui/ # shadcn/ui component library
│ │ ├── context/
│ │ │ └── auth.tsx # Auth context + token state
│ │ ├── hooks/ # Custom React hooks
│ │ └── lib/
│ │ ├── api.ts # Axios instance with auth interceptors
│ │ ├── auth.ts # Token helpers
│ │ └── schema.ts # Zod validation schemas
│ ├── Dockerfile
│ ├── react-router.config.ts
│ ├── vite.config.ts
│ └── package.json
├── sql/
│ ├── schema/ # goose migrations (6 files)
│ └── queries/ # sqlc source queries
├── sqlc.yaml
├── Makefile
└── .env
| Layer | Choice | Reason |
|---|---|---|
| Language | Go | Cheap goroutines, great for background workers |
| Frontend | React Router v7 + Vite | SPA mode, file-based routing, fast HMR |
| UI Components | shadcn/ui (Radix UI + Tailwind v4) | Accessible, unstyled primitives with Tailwind |
| Data Fetching | TanStack Query | Caching, background refetch, loading states |
| Forms | React Hook Form + Zod | Performant forms with schema validation |
| Charts | Recharts | Composable charts for price history |
| HTTP Client | Axios | Interceptors for auth token injection |
| Database | PostgreSQL | Relational integrity, battle-tested |
| DB Queries | sqlc | Type-safe SQL, no ORM magic |
| Migrations | goose | File-based, CI-friendly |
| HTTP Router | Gin | Lightweight, composable middleware |
| Job Scheduling | robfig/cron | Battle-tested Go cron library |
| go-mail + SMTP | Standard SMTP, works with any provider | |
| Scraping | Apify | Managed Nike scraper actor |
| Auth | JWT + Argon2id | Stateless access tokens + secure password hashing |
| Config | env vars + godotenv | 12-factor app compliant |
| Feature | Free | Pro |
|---|---|---|
| Watchlist slots | 5 products | Unlimited |
| Scrape frequency | Every 5 min | Every 5 min |
| Alert channels | ||
| Price history | Full | Full |
| Notification preferences | — | Toggle email alerts on/off |
All authenticated endpoints require Authorization: Bearer <access_token>.
POST /v1/register Create a new account
Body: { name, email, password }
POST /v1/login Authenticate and get access token
Body: { email, password }
Response: { user, token }
Cookie set: refresh_token (httpOnly)
GET /v1/refresh Rotate tokens using the refresh_token cookie
No body or header needed — browser sends cookie automatically
Response: { access_token }
Cookie set: refresh_token (rotated, httpOnly)
POST /v1/logout Clear the refresh token cookie
Response: 204 No Content
PUT /v1/activation Activate account
Body: { token }
POST /v1/products Scrape and add a product by Nike URL
Body: { product_url }
Response: 202 Accepted (scraping is async)
GET /v1/products Search catalog
Query: ?q=&category=&in_stock=&min_price=&max_price=&page=&limit=
GET /v1/products/:id Get product details
GET /v1/products/:id/price-history Get price + stock history
Query: ?limit= (default: 50, max: 200)
POST /v1/watchlist Add a product to watchlist
Body: { product_id, alert_sale, alert_restock }
GET /v1/watchlist Get all watched products with current price/stock
PATCH /v1/watchlist/:id Update alert preferences
Body: { alert_sale, alert_restock }
DELETE /v1/watchlist/:id Remove from watchlist
GET /v1/notifications Get notification history
Query: ?page=&page_size=&unread=true
PATCH /v1/notifications/:id/read Mark a notification as read
PATCH /v1/notifications/read-all Mark all notifications as read
PATCH /v1/users/me/notifications Toggle email notifications
Body: { notify_email: true | false }
Returns 403 for free-tier users
GET /v1/healthcheck Returns status, environment, and version
| Variable | Required | Description |
|---|---|---|
PORT |
Yes | Server port (e.g. 4000) |
ENV |
Yes | development or production — controls cookie Secure flag |
KICK_ALERT_DB_DSN |
Yes | PostgreSQL connection string |
DB_MAX_OPEN_CONNS |
Yes | Max open DB connections |
DB_MAX_IDLE_CONNS |
Yes | Max idle DB connections |
DB_MAX_IDLE_TIME |
Yes | Connection idle timeout in minutes |
JWT_SECRET |
Yes | Secret key for signing JWTs |
FRONTEND_ACTIVATION_URL |
Yes | Base URL for activation email link (e.g. http://localhost:3000/activate) |
ALLOWED_ORIGINS |
Yes | Comma-separated list of allowed CORS origins (e.g. http://localhost:3000,https://kickalert.com) |
SMTP_HOST |
Yes | SMTP server hostname |
SMTP_PORT |
Yes | SMTP server port |
SMTP_USERNAME |
Yes | SMTP username |
SMTP_PASSWORD |
Yes | SMTP password |
SMTP_SENDER |
Yes | Sender address (e.g. KickAlert <no-reply@example.com>) |
APIFY_TOKEN |
Yes | Apify API token for the Nike scraper actor |
LIMITER_ENABLED |
No | Enable rate limiting (default: false) |
LIMITER_RPS |
No | Requests per second per IP (default: 2) |
LIMITER_BURST |
No | Burst size (default: 4) |
# Run migrations
make db/migrations/up
# Start API (includes background scheduler)
make run/api
# Start frontend dev server (in a separate terminal)
make install/deps # The frist time run this command
make run/frontendThe frontend dev server runs at http://localhost:5173.
- PostgreSQL schema + goose migrations
- User registration with email activation
- JWT auth with access + refresh tokens
- Product catalog with Apify scraping
- Watchlist CRUD with tier-based limits
- Background scheduler + price change detection
- Email notifications via SMTP
- Notifications endpoint with read/unread state
- Price history endpoint
- httpOnly cookie-based refresh token flow (SameSite=Lax in dev, SameSite=None in production)
- CORS with credentials + multi-origin support
- Pro-only notification preferences endpoint
- React Router v7 frontend: auth pages (login, register, activate)
- React Router v7 frontend: watchlist dashboard + product detail + notifications + settings
- Stripe integration (free → pro upgrade)
- Webhook delivery (Discord, Telegram, custom URL)