Turn your PDF resume into a hosted web portfolio in under 60 seconds.
Upload a PDF. AI parses it. Get a shareable link.
- Instant PDF Parsing - AI extracts your information automatically
- Clean Public URLs - Get
yoursite.com/yournameimmediately - Privacy Controls - Show/hide phone numbers and addresses
- Multiple Templates - Professional, modern designs
- Mobile Responsive - Looks great on all devices
- SEO Optimized - Proper metadata, Open Graph tags
| Layer | Technology |
|---|---|
| Framework | vinext (Vite-based Next.js) |
| Runtime | Cloudflare Workers |
| Database | PlanetScale Postgres via Cloudflare Hyperdrive + Drizzle ORM (node-postgres pg >= 8.16.3) |
| Auth | Clerk (Google OAuth + credentials; prebuilt <SignIn>/<SignUp> UI, JWKS-verified session JWTs) |
| Storage | Cloudflare R2 (S3-compatible) |
| AI Parsing | OpenRouter via Cloudflare AI Gateway (openai/gpt-oss models) |
| Styling | shadcn/ui + Tailwind CSS 4 |
We chose Cloudflare Workers over traditional hosting for several reasons:
- Edge Computing: Code runs in 300+ data centers worldwide, closest to your users
- Cold Start: ~0ms cold starts vs. 200-500ms on traditional serverless
- Latency: Sub-50ms response times globally
- Free Tier: 100,000 requests/day free
- Hyperdrive: Free connection pooling and query caching for Postgres at the edge
- R2 Storage: 10GB free, no egress fees
- Total: A production app can run free for most use cases (plus PlanetScale/Clerk free tiers)
- No Container Management: Just deploy code
- Automatic Scaling: From 0 to millions of requests
- Integrated Stack: Hyperdrive, R2, Workflows, and Durable Objects work seamlessly together
- No
fsModule: Must use R2 for file operations - No Next.js
<Image />Component: Use<img>with CSS instead - No DB in Middleware: The edge proxy only checks cookie presence
- Bundle Size: Keep dependencies minimal
- pnpm v11+ (package manager)
- Cloudflare Account with R2 + Hyperdrive enabled
- PlanetScale Postgres database (free tier works)
- Clerk account (free tier works)
- OpenRouter account for AI parsing
# Clone the repository
git clone https://github.com/divkix/clickfolio.me.git
cd clickfolio.me
# Install dependencies
pnpm install
# Copy environment template and fill in your values
cp .env.example .dev.vars
# Apply database migrations (needs DATABASE_URL from PlanetScale)
DATABASE_URL="postgres://β¦" pnpm run db:migrate
# Start development server
pnpm run devIf you are not technical, follow this exact checklist. You only need a terminal and browser.
What you need
- A Cloudflare account (free is fine)
- A PlanetScale account (Postgres database, free tier is fine)
- A Clerk account (authentication, free tier is fine)
- An OpenRouter account (for AI parsing)
- pnpm installed (copy/paste this in Terminal):
npm install -g pnpm
Step 0: Get the code
- Download the repo ZIP from GitHub and unzip it, or use:
git clone https://github.com/divkix/clickfolio.me.git cd clickfolio.me - Install dependencies:
pnpm install
Step 1: Create the PlanetScale Postgres database
- Create a Postgres database in the PlanetScale console (e.g.
clickfolio). - Copy the direct connection string (PlanetScale console β your database β Connect β Postgres URL).
- You will use it as
DATABASE_URLbelow. Note: drizzle-kit uses this DIRECT URL; the deployed Worker connects through Cloudflare Hyperdrive instead.
Step 2: Create the Cloudflare Hyperdrive binding
- In Terminal:
pnpm exec wrangler hyperdrive create clickfolio-pg --connection-string="postgres://user:password@host/db"
- Copy the printed Hyperdrive
id. - Open
wrangler.jsoncand put that id underhyperdrive[0].id.
Step 3: Create Cloudflare R2 bucket
- Go to Cloudflare Dashboard β R2 β Create bucket.
- Name it
clickfolio-bucket. - The bucket is accessed via binding in wrangler.jsonc - no API tokens needed.
Step 4: Configure R2 CORS In Cloudflare R2 bucket settings β CORS, paste:
[
{
"AllowedOrigins": ["http://localhost:3000", "https://your-domain.com"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3000
}
]Step 5: Set up Clerk
- Create an application at clerk.com β copy the Publishable key (
pk_β¦) and Secret key (sk_β¦). - Enable the Google social connection in Clerk's dashboard (Clerk hosts the OAuth app β no separate Google Cloud project needed).
- Create a webhook in Clerk β Webhooks pointing to
https://your-domain.com/api/webhooks/clerk, subscribing touser.created,user.updated,user.deleted. Copy the signing secret (whsec_β¦).
Step 6: Set up OpenRouter
- Create OpenRouter account β API Keys.
- Copy your API key.
Step 7: Add secrets to Cloudflare (production) Run each command and paste the value when prompted:
pnpm exec wrangler secret put CLERK_SECRET_KEY # sk_β¦ from Clerk
pnpm exec wrangler secret put CLERK_WEBHOOK_SECRET # whsec_β¦ from Clerk webhook
pnpm exec wrangler secret put NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY # pk_β¦ from Clerk
pnpm exec wrangler secret put APP_URL # https://your-domain.com
openssl rand -base64 32 # then:
pnpm exec wrangler secret put PENDING_UPLOAD_SECRET # random value from openssl above
pnpm exec wrangler secret put CF_AI_GATEWAY_ACCOUNT_ID
pnpm exec wrangler secret put CF_AI_GATEWAY_ID
pnpm exec wrangler secret put CF_AIG_AUTH_TOKENStep 8: Deploy
DATABASE_URL="postgres://β¦" pnpm run deploy # build β db:migrate β R2 lifecycle β wrangler deployStep 9: Add your domain Cloudflare Dashboard β Workers & Pages β your worker β Settings β Domains & Routes.
Important: After domain is connected, update these secrets and redeploy:
APP_URL=https://your-domain.com- Point the Clerk webhook endpoint URL at
https://your-domain.com/api/webhooks/clerk
Then redeploy:
pnpm run deployIf you followed the steps above, the site should be live at your domain.
-
Create a Cloudflare account at cloudflare.com
-
Create the Hyperdrive binding over your PlanetScale Postgres:
pnpm exec wrangler hyperdrive create clickfolio-pg --connection-string="postgres://β¦"
Copy the
idtowrangler.jsonc -
Create R2 Bucket
- Go to Cloudflare Dashboard > R2
- Create bucket named
clickfolio-bucket - The bucket is accessed via binding in
wrangler.jsonc- no API tokens needed
-
Configure R2 CORS Add CORS policy in R2 bucket settings:
[ { "AllowedOrigins": ["http://localhost:3000", "https://your-domain.com"], "AllowedMethods": ["GET", "PUT", "POST"], "AllowedHeaders": ["*"], "MaxAgeSeconds": 3000 } ]
- Create an application at clerk.com
- Copy the Publishable key (
pk_β¦) and Secret key (sk_β¦) - Enable Google sign-in in Clerk's dashboard (Clerk manages the OAuth app)
- Add a webhook endpoint
https://your-domain.com/api/webhooks/clerksubscribed touser.created,user.updated,user.deleted; copy its signing secret (whsec_β¦)
- Create account at openrouter.ai
- Go to API Keys
- Create new API key and copy it
- Get your OpenRouter HTTP Referer and App Title from the dashboard
Cloudflare AI Gateway This project uses Cloudflare AI Gateway for AI calls.
- Go to Cloudflare Dashboard > AI > AI Gateway
- Create a gateway
- Store your OpenRouter token in Cloudflare Secrets Store
- You will use
CF_AI_GATEWAY_*environment variables
Create .dev.vars for development:
# Generate a secure secret with: openssl rand -base64 32
APP_URL=http://localhost:3000
PENDING_UPLOAD_SECRET=your-generated-secret
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_β¦
CLERK_SECRET_KEY=sk_test_β¦
CLERK_WEBHOOK_SECRET=whsec_β¦
# Direct PlanetScale URL β used ONLY by drizzle-kit locally (db:migrate/push/studio).
# The dev Worker itself connects through the local Hyperdrive simulation.
DATABASE_URL=postgres://user:password@host/clickfolio
# Cloudflare AI Gateway (BYOK - OpenRouter key stored in CF Secrets Store)
CF_AI_GATEWAY_ACCOUNT_ID=your-account-id
CF_AI_GATEWAY_ID=your-gateway-id
CF_AIG_AUTH_TOKEN=your-gateway-auth-tokenSee .env.example for complete template with all options.
-
Apply database migrations
DATABASE_URL="postgres://β¦" pnpm run db:migrate -
Set production secrets (see Step 7 of the beginner guide above)
-
Deploy
pnpm run deploy
-
Configure custom domain (optional)
- In Cloudflare Dashboard > Workers & Pages > Your Worker
- Add custom domain in Settings > Domains & Routes
# Development
pnpm run dev # Start dev server at localhost:3000
pnpm run lint # Oxlint linting (via vp lint)
pnpm run fix # Oxlint + Oxfmt auto-fix (via vp check --fix)
pnpm run type-check # TypeScript check
# Build & Deploy
pnpm run build # Vite production build (vinext)
pnpm run preview # Local Cloudflare preview
pnpm run deploy # scripts/deploy.ts: build, db:migrate, R2 lifecycle, wrangler deploy, IndexNow (needs DATABASE_URL)
# Database (PlanetScale Postgres via drizzle-kit; needs DATABASE_URL except generate)
pnpm run db:generate # Generate migration files into migrations_pg/
pnpm run db:migrate # Apply migrations_pg/ to DATABASE_URL
pnpm run db:push # Sync schema without migration files (prototyping only)
pnpm run db:studio # Drizzle Studio UI (port 4984)
# Testing
pnpm run test # All tests
pnpm run test:unit # Unit tests (fast, no retries)
pnpm run test:integration # Integration tests
pnpm run test:security # Security tests
pnpm run test:coverage # All tests + coverage
pnpm run test:ci # CI mode (JSON reporter)
pnpm run test:ui # Interactive UI mode
# Quality
pnpm run verify # check + type-check + knip
pnpm run format # Format with Oxfmt
pnpm run seo:lastmod # Update SEO last-modified timestamps
pnpm run ci # type-check + lint + test + buildapp/
βββ api/ # API routes (webhooks/clerk, upload, resume, etc.)
βββ (admin)/ # Admin dashboard pages
β βββ users/ # User management
β βββ resumes/ # Resume management
β βββ analytics/ # Site analytics
βββ (protected)/ # Auth-gated pages
β βββ dashboard/ # User dashboard with analytics
β βββ edit/ # Resume content editor
β βββ settings/ # Privacy & theme settings
β βββ themes/ # Theme gallery
β βββ waiting/ # AI parsing status (WebSocket)
β βββ wizard/ # Onboarding wizard
βββ [handle]/ # Public resume viewer /@handle
βββ for/ # Landing pages by profession
β βββ student/
β βββ software-engineer/
β βββ designer/
β βββ product-manager/
β βββ marketer/
β βββ consultant/
βββ blog/ # Blog posts & content marketing
βββ preview/[id]/ # Template preview (before claiming)
βββ page.tsx # Homepage
βββ layout.tsx # Root layout (ClerkProvider wrapper)
βββ globals.css # Global styles
components/
βββ templates/ # 14 resume template components
βββ ui/ # shadcn/ui components
βββ auth/ # LoginButton using Clerk's native sign-in modal
βββ dashboard/ # Dashboard-specific components
βββ icons/ # Custom icon components
βββ analytics/ # Analytics components
βββ *.tsx # Shared components (Footer, Logo, etc.)
lib/
βββ auth/ # Clerk integration (server JWKS verification, session, client seam)
βββ ai/ # AI parsing (OpenRouter via CF AI Gateway)
βββ cron/ # Daily DB cleanup cron
βββ db/ # Drizzle PG schema + getDb(env.HYPERDRIVE)
βββ durable-objects/ # WebSocket Durable Object
βββ parse/ # Parse step bodies, error classification, alerts, DO notify
βββ workflows/ # ResumeParseWorkflow + R2DeleteWorkflow and their triggers
βββ schemas/ # Zod validation schemas
βββ templates/ # Theme registry & metadata
βββ types/ # TypeScript type definitions
βββ utils/ # Utility functions
βββ blog/ # Blog post data
βββ config/ # Site config, FAQ, retry policies
worker/
βββ index.ts # Custom worker entry (vinext + Workflows + Cron + WebSocket auth)
migrations_pg/
βββ *.sql # Postgres migrations (drizzle-kit)
tests/
βββ unit/ # Unit tests
βββ integration/ # Integration tests
βββ security/ # Security tests (IDOR, rate limits)
βββ setup.ts # Test configuration
Allows anonymous users to upload before authenticating:
1. POST /api/upload β Upload file directly to Worker
2. Worker stores in R2 β Signed pending_upload cookie (HMAC'd with PENDING_UPLOAD_SECRET)
3. User authenticates β Clerk (Google OAuth or credentials)
4. POST /api/resume/claim β Link upload to user, trigger parsing
5. Poll /api/resume/status β Wait for AI parsing (~30-40s)
Before rendering public profiles:
- Phone numbers: Hidden by default
- Addresses: City/State only (full address hidden)
- Email: Public (for contact)
- User controls visibility in settings
Live status updates during AI parsing:
- Endpoint:
wss://your-domain.com/ws/resume-status?resume_id={id} - Technology: Cloudflare Durable Objects (
ClickfolioStatusDO) - Flow: WebSocket connection β DO tracks parsing progress β Real-time status pushed to client
- Authentication: Clerk session JWT verified against JWKS before upgrade
- Use case: Waiting room shows live parsing progress instead of polling
Durable resume parsing with per-step retries:
- Workflow:
ResumeParseWorkflow(lib/workflows/resume-parse-workflow.ts) runsclaim β parse β complete - Trigger:
/api/resume/claimand/api/resume/retrystart an instance keyed by the resume id, so a repeated start is a no-op - Retries: the parse step retries transient failures 3 times with exponential backoff; permanent errors throw
NonRetryableError - Failure: once retries are spent the workflow marks the row
failed, notifies the Durable Object, and sends an alert - Duplicate uploads: an
await-cacheinstance sleeps 10 minutes, then expires awaiting_for_cacherow that never resolved - Alerting: Cloudflare Logpush by default, optional Slack/Discord webhook on permanent failures
- Anonymous uploads: an R2 lifecycle rule (
r2-lifecycle.json, applied bypnpm run deploy) deletestemp/objects after 1 day - Deletions:
R2DeleteWorkflowretries failed deletes (account deletion, admin dismiss) with backoff
| Cron | Time (UTC) | Task |
|---|---|---|
0 3 * * * |
3:00 AM | Database cleanup (expired rate limits, handle history) |
Runs via worker/index.ts without self-fetch (avoids double billing).
Removed β all 14 templates are now free for every user. No referral gating.
14 built-in templates in components/templates/:
| Template | Category | Description | Unlock Requirement |
|---|---|---|---|
| Minimalist Editorial | Professional | Clean magazine-style layout with serif typography | Free (default) |
| Neo Brutalist | Creative | Bold design with thick borders and loud colors | Free |
| Glass Morphic | Modern | Dark theme with frosted glass effects | Free |
| Bento Grid | Modern | Modern mosaic layout with colorful cards | Free |
| Classic ATS | Professional | Legal brief typography, ATS-optimized single-column layout | Free |
| DevTerminal | Developer | GitHub-inspired dark terminal aesthetic for developers | Free |
| DesignFolio | Creative | Digital brutalism meets Swiss typography with acid lime accents | Free |
| Spotlight | Creative | Warm creative portfolio with animated sections | Free |
| Midnight | Modern | Dark minimal with serif headings and gold accents | Free |
| Boardroom | Professional | Dark executive ledger with a pinned identity column, brass accents | Free |
| Bold Corporate | Professional | Executive typography with bold numbered sections | Free |
| Broadsheet | Professional | Newspaper front page: masthead name, ruled columns, roles as stories | Free |
| Case File | Professional | Typed dossier in a manila folder, sections filed as exhibits | Free |
| Retro OS | Creative | Late-90s desktop, every section in its own bevelled window | Free |
All templates receive content (ResumeContent) and profile props, respect privacy settings, and are mobile-responsive.
- Application-Level Authorization: All data access controlled in code
- Rate Limiting: 5 resume uploads/day per user, plus IP-based limits (10/hour, 50/day) for anonymous uploads
- Input Validation: Zod schemas on all endpoints
- XSS Protection: React's default sanitization
- Encrypted Secrets: All secrets encrypted in Cloudflare; Clerk session JWTs verified against JWKS on every server request
- Webhook Signatures: Clerk webhooks are Svix-signature verified before processing
- Privacy Controls: Users control visibility of phone numbers and addresses
- IP Privacy: IP addresses SHA-256 hashed before storage (GDPR-friendly)
To report a vulnerability, open a GitHub issue with the "security" label or contact the maintainers directly via the repository's GitHub page.
Contributions welcome! See AGENTS.md for branch conventions, commit style, and the pnpm run ci quality gate.
- Fork the repository
- Create a feature branch (
git checkout -b feat/amazing-feature) - Use conventional commits (
feat:,fix:,docs:) - Run quality checks (
pnpm run ci) - Submit a pull request
pnpm run type-check # See all errors
pnpm run build # Fix errors and rebuild- Verify
CLERK_SECRET_KEY/NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYmatch the same Clerk application - Confirm the Clerk webhook points at
/api/webhooks/clerkandCLERK_WEBHOOK_SECRETmatches β a signed-in user with no synced row gets 404s until the webhook lands - Clear browser cookies
- Verify the Hyperdrive binding id in
wrangler.jsoncmatcheswrangler hyperdrive createoutput - For
db:*scripts, confirmDATABASE_URLis set to the DIRECT PlanetScale connection string - Check PlanetScale console β your database is awake and credentials are valid
- Check R2 CORS includes your domain
- Verify R2 bucket binding is configured in
wrangler.jsonc - Confirm bucket name in binding matches actual bucket
- Verify CF AI Gateway config and OpenRouter BYOK setup
- Check PDF isn't corrupted
- Use retry button (max 2 retries)
You're on Cloudflare Workers. Use R2 bindings for file operations.
MIT License - see LICENSE for details.
- vinext - Vite-based Next.js for Cloudflare Workers
- Clerk - Authentication and user management
- PlanetScale - Serverless Postgres
- Drizzle ORM - Type-safe database access
- Cloudflare - Edge infrastructure
- OpenRouter - AI API gateway
- OpenAI - AI inference
- shadcn/ui - UI components (built on Radix UI + Tailwind CSS)
Built with TypeScript. Deployed on the edge. Designed for speed.
