A production-grade creator monetization platform. Creators upload courses and tutorials; subscribers pay once for lifetime access via Razorpay.
Stack: React 18 Β· Vite Β· Tailwind CSS Β· Node.js Β· Express Β· PostgreSQL Β· JWT Β· Razorpay
- Architecture
- Local Development Setup
- Environment Variables
- Database Setup
- Running the App
- Project Structure
- API Reference
- Deployment
- Known Design Decisions
βββββββββββββββββββ HTTPS ββββββββββββββββββββββββ
β React (Vite) β βββββββββββΆ β Express (Node.js) β
β Vercel CDN β β Railway / Render β
βββββββββββββββββββ ββββββββββββ¬ββββββββββββ
β
βΌ
ββββββββββββββββββββββββ
β PostgreSQL β
β Railway / Neon β
ββββββββββββββββββββββββ
β²
β webhook
ββββββββββββββββββββββββ
β Razorpay β
β (payment gateway) β
ββββββββββββββββββββββββ
JWT role sync: When a user becomes a creator, the backend issues a new JWT with role='creator'. The frontend stores this immediately. Without this, the old token still says subscriber and every creator API call fails.
Subscription state machine:
pending ββ(webhook)βββΆ active ββ(cancel)βββΆ cancelled
β
Only active grants content access
Content access IDs: content.creator_id β creators.id (not users.id). The JWT carries users.id. The content table's JOIN with creators exposes creator_user_id to safely do the creator-bypass check in middleware.
403 with metadata: When a subscriber without access hits a paid content endpoint, the server returns 403 with full content metadata so the frontend can render a locked-preview + purchase CTA without a second request.
- Node.js 18+
- PostgreSQL 14+ running locally
- A Razorpay test account (free at razorpay.com)
git clone https://github.com/yourusername/creator-dashboard.git
cd creator-dashboard
# Install server dependencies
cd server && npm install && cd ..
# Install client dependencies
cd client && npm install && cd ..# Create the database
psql -U postgres -c "CREATE DATABASE creator_dashboard;"
# Run schema (creates all tables and indexes)
psql -U postgres -d creator_dashboard -f database/schema.sql
# Optional: load test data
psql -U postgres -d creator_dashboard -f database/seed.sqlcd server
cp .env.example .env
# Edit .env with your DB credentials and Razorpay test keysMinimum required values for local dev:
DB_HOST=localhost
DB_PORT=5432
DB_NAME=creator_dashboard
DB_USER=postgres
DB_PASSWORD=your_postgres_password
JWT_SECRET=any_long_random_string_here
RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxxxxxxx
RAZORPAY_KEY_SECRET=your_test_secret
RAZORPAY_WEBHOOK_SECRET=your_webhook_secretFor local webhook testing, use ngrok:
ngrok http 5000
# Copy the https URL, e.g. https://abc123.ngrok.io
# In Razorpay dashboard β Webhooks β Add new
# URL: https://abc123.ngrok.io/api/payments/webhook
# Events: payment.authorized
# Copy the webhook secret into RAZORPAY_WEBHOOK_SECRET| Variable | Required | Description |
|---|---|---|
NODE_ENV |
No | development or production |
PORT |
No | Server port (default: 5000) |
DATABASE_URL |
Either/or | Full connection string (cloud) |
DB_HOST |
Either/or | PostgreSQL host (local) |
DB_PORT |
Either/or | PostgreSQL port (local) |
DB_NAME |
Either/or | Database name (local) |
DB_USER |
Either/or | Database user (local) |
DB_PASSWORD |
Either/or | Database password (local) |
JWT_SECRET |
Yes | Secret for signing JWTs (min 32 chars) |
RAZORPAY_KEY_ID |
Yes | Razorpay API key ID |
RAZORPAY_KEY_SECRET |
Yes | Razorpay API key secret |
RAZORPAY_WEBHOOK_SECRET |
Yes | Razorpay webhook secret |
CORS_ORIGIN |
Prod only | Frontend URL(s), comma-separated |
| Variable | Description |
|---|---|
VITE_API_URL |
Backend URL in production (blank = use Vite proxy) |
# Terminal 1 β Backend
cd server
npm run dev # Node --watch restarts on file change
# Terminal 2 β Frontend
cd client
npm run dev # Vite dev server on http://localhost:3000Visit http://localhost:3000
creator-dashboard/
βββ database/
β βββ schema.sql # All tables, indexes, views
β βββ seed.sql # Test data
β
βββ server/
β βββ server.js # Express app entry point
β βββ package.json
β βββ .env.example
β βββ Dockerfile
β βββ render.yaml
β β
β βββ config/
β β βββ db.js # PostgreSQL pool (supports DATABASE_URL)
β β
β βββ models/ # Database access layer
β β βββ User.js
β β βββ Creator.js
β β βββ Content.js # findById includes creator info
β β βββ Subscription.js # findByUserId returns content_title alias
β β
β βββ controllers/ # Business logic
β β βββ authController.js
β β βββ creatorController.js
β β βββ contentController.js # uploadContent uses creator.id not user.id
β β βββ subscriptionController.js
β β βββ paymentController.js
β β
β βββ routes/ # Express routers
β β βββ auth.js
β β βββ creators.js
β β βββ content.js
β β βββ subscriptions.js
β β βββ payments.js
β β
β βββ middleware/
β β βββ auth.js # JWT verification
β β βββ contentAccess.js # Access control (uses creator_user_id from JOIN)
β β βββ authorization.js # Ownership checks
β β
β βββ utils/
β βββ tokenGenerator.js
β βββ razorpay.js
β βββ amountConverter.js # INR β paise conversions
β βββ webhookVerifier.js # HMAC-SHA256 signature check
β βββ validators.js
β
βββ client/
β βββ index.html # Loads Razorpay SDK + Google Fonts
β βββ vite.config.js # Proxy /api to backend
β βββ tailwind.config.js
β βββ vercel.json # SPA routing rewrites
β β
β βββ src/
β βββ App.jsx # React Router v6 routes
β βββ main.jsx
β βββ index.css # Tailwind + component classes
β β
β βββ context/
β β βββ AuthContext.jsx # JWT storage, becomeCreator token refresh
β β βββ ToastContext.jsx
β β
β βββ utils/
β β βββ api.js # All API calls; errors carry status + data
β β βββ razorpayLoader.js # Dynamic SDK loading
β β
β βββ components/
β β βββ Navbar.jsx
β β βββ ProtectedRoute.jsx
β β βββ ContentCard.jsx
β β βββ ui.jsx # LoadingSpinner, EmptyState, etc.
β β
β βββ pages/
β βββ LoginPage.jsx
β βββ RegisterPage.jsx
β βββ BrowseContentPage.jsx
β βββ ContentDetailPage.jsx # State machine: accessible/locked/requires_auth
β βββ CheckoutPage.jsx # Razorpay + polling
β βββ MySubscriptionsPage.jsx
β βββ DashboardPage.jsx # Creator tabs + subscriber CTA
β
βββ README.md
| Method | Endpoint | Auth | Body | Response |
|---|---|---|---|---|
| POST | /api/auth/register |
β | {email, username, password, passwordConfirm} |
{user} |
| POST | /api/auth/login |
β | {email, password} |
{token, user} |
| Method | Endpoint | Auth | Body | Response |
|---|---|---|---|---|
| POST | /api/creators/become-creator |
Bearer | {displayName} |
{creator, token} β new token! |
| GET | /api/creators/me |
Bearer | β | creator object |
| PUT | /api/creators/:id |
Bearer | {displayName, bio, bankAccount} |
{creator} |
| Method | Endpoint | Auth | Body | Response |
|---|---|---|---|---|
| GET | /api/content/browse |
β | β | {content[]} |
| GET | /api/content/:id |
Optional | β | content or 401/403/404 |
| POST | /api/content/:id/view |
Optional | β | {views_count} |
| POST | /api/content |
Creator | {title, description?, fileUrl?, price} |
{content} |
| PUT | /api/content/:id |
Creator | {title?, description?, fileUrl?, price?} |
{content} |
| DELETE | /api/content/:id |
Creator | β | {content} |
| GET | /api/content/my |
Creator | β | {content[]} |
| Method | Endpoint | Auth | Body | Response |
|---|---|---|---|---|
| POST | /api/subscriptions |
Bearer | {contentId} |
{subscription} (status: pending) |
| GET | /api/subscriptions |
Bearer | β | {subscriptions[], summary} |
| DELETE | /api/subscriptions/:id |
Bearer | β | {subscription} (status: cancelled) |
| POST | /api/subscriptions/:id/activate-testing |
Bearer | β | dev only |
| Method | Endpoint | Auth | Body | Response |
|---|---|---|---|---|
| POST | /api/payments/create-order |
Bearer | {subscriptionId} |
{order: {orderId, amount, keyId}} |
| POST | /api/payments/webhook |
Signature | Razorpay payload | {status: ok} |
| POST | /api/payments/verify |
Bearer | {subscriptionId} |
200 (active) or 202 (pending) |
Step 1: Deploy database (Neon.tech β free PostgreSQL)
- Create account at neon.tech
- Create a new project β copy the connection string
- Run schema:
psql "your_connection_string" -f database/schema.sql
Step 2: Deploy backend to Render
- Push your repo to GitHub
- Go to render.com β New β Web Service
- Connect your GitHub repo, set root directory to
server - Build command:
npm install| Start command:npm start - Add environment variables (from
.env.example):DATABASE_URL= your Neon connection stringJWT_SECRET= generate withnode -e "console.log(require('crypto').randomBytes(64).toString('hex'))"RAZORPAY_KEY_ID,RAZORPAY_KEY_SECRET,RAZORPAY_WEBHOOK_SECRETNODE_ENV=production
- Note your Render URL (e.g.
https://creator-api.onrender.com)
Step 3: Deploy frontend to Vercel
- Go to vercel.com β New Project β Import your repo
- Set root directory to
client - Build command:
npm run build| Output directory:dist - Add environment variable:
VITE_API_URL= your Render backend URL - Deploy β note your Vercel URL
Step 4: Update CORS
- In Render, set
CORS_ORIGIN= your Vercel URL (e.g.https://creator-dashboard.vercel.app)
Step 5: Configure Razorpay webhook
- In Razorpay dashboard β Settings β Webhooks β Add new
- URL:
https://your-render-url.onrender.com/api/payments/webhook - Events:
payment.authorized - Copy secret β set as
RAZORPAY_WEBHOOK_SECRETin Render
Why creators.id β users.id?
The content table links to the creators table (not users) via creator_id. This enables future multi-creator features. The middleware joins creators to get creator_user_id for safe comparison with JWT's users.id.
Why no refresh tokens? Kept simple for MVP. The 1-hour JWT expiry is short enough for security. A TODO comment is left for refresh token implementation in a future iteration.
Why is payment verification done by polling?
Razorpay's webhook is async and may arrive seconds after the user's payment completes. The frontend polls POST /verify every 2 seconds (max 12 attempts = 24 seconds) and shows a spinner. This is the standard pattern for Razorpay integrations.
Why partial unique index for subscriptions?
UNIQUE (user_id, content_id) WHERE status='active' allows a user to have multiple pending subscriptions to the same content (e.g., retrying payment) while preventing two active subscriptions. Only the webhook-activated one counts.
File Storage: Content is currently stored as a URL reference rather than a hosted file. Phase 2 would replace this with S3-backed private storage and signed URL generation for time-limited access. Creators should use private/unlisted external hosting for meaningful content protection.