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 | TanStack Start (React 19, Vite, file routes + server functions) |
| 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 image optimizer: Use
<img>with CSS instead - No DB in the Worker gate: The Worker entry only strips/forwards requests; pages and APIs authenticate themselves
- 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
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_TOKENSet the public values in wrangler.jsonc β vars, not as secrets:
CLERK_PUBLISHABLE_KEY= your Clerk publishable key (pk_β¦)UMAMI_WEBSITE_ID= your Umami website id (public; the tracker script prints it)APP_URL=https://your-domain.com
Step 8: Deploy
DATABASE_URL="postgres://β¦" pnpm run deployDeploy builds, migrates, backfills NULL content_indexable flags, applies R2 lifecycle
rules, and deploys the Worker. It then backfills rows updated since deploy start
minus one hour and sends a best-effort IndexNow ping.
Step 9: Add your domain Cloudflare Dashboard β Workers & Pages β your worker β Settings β Domains & Routes.
Important: After the domain is connected, update wrangler.jsonc β vars and redeploy:
APP_URL=https://your-domain.com- Point the Clerk webhook endpoint URL at
https://your-domain.com/api/webhooks/clerk
Email notifications: Permanent resume parse failures and unresolved cache waits
send a dashboard link to the owner's verified Clerk account email. Email delivery is
best effort and never blocks the resume status update. Successful parses do not email.
Use a Workers Paid plan and onboard your sending domain under Cloudflare Dashboard β
Compute β Email Service β Email Sending β Onboard Domain. Set EMAIL_FROM and the
EMAIL binding's allowed_sender_addresses in wrangler.jsonc to your sender
(default: notifications@clickfolio.me). No email API key is needed.
See Cloudflare's sending setup.
Local email sends are simulated. Adding remote: true to the EMAIL binding sends
real mail during development; only enable it for deliberate delivery testing.
Cloudflare Email Sending is currently beta. Email runs in its own journaled workflow
step with automatic retries disabled: normal replay reuses the completed step,
but a crash after provider acceptance and before persistence can still duplicate a
notification. Delivery errors are logged, without recipient addresses, and are not retried.
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
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
DATABASE_URL="postgres://β¦" 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 (TanStack Start + Cloudflare plugin)
pnpm run preview # Local Cloudflare preview
pnpm run deploy # build β migrate β NULL backfill β R2 lifecycle β deploy β since backfill β 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 # Integration and security tests
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 + buildTests cover integration and security flows. CI collects coverage (including
worker/) and enforces the measured floors in vite.config.ts
(test.coverage.thresholds).
Dependabot checks weekly (Monday 09:00 UTC) with a 7-day cooldown, so a
just-published release waits a week before it is proposed (security updates skip
the cooldown). Vite+ toolchain packages (vite-plus, the vite catalog alias,
vitest, @vitest/*, @voidzero-dev/*) are grouped into one minor/patch PR
because they only move in lockstep. Other patches arrive as one grouped PR;
minors and majors get one PR per package. GitHub Actions updates are grouped.
Nothing merges automatically: the owner reads each PR's changelog, migrates code where needed and merges by hand.
app/
βββ routes/ # TanStack Router file routes (routeTree.gen.ts is generated)
β βββ api/ # Server routes (webhooks/clerk, upload, resume, etc.)
β βββ admin/ # Admin dashboard (route.tsx gates; users, resumes, analytics)
β βββ _protected/ # Auth-gated pages: dashboard, edit, settings, themes, waiting, wizard
β βββ @{$handle}.tsx # Public resume viewer /@handle
β βββ for/ # Landing pages by profession
β βββ blog/ # Blog posts & content marketing
β βββ preview/ # Template preview (demo data)
β βββ index.tsx # Homepage
β βββ __root.tsx # Root document (ClerkProvider, head, scripts)
βββ router.tsx # Router factory (error/not-found defaults)
βββ client.tsx # Client entry (hydration + browser telemetry)
βββ globals.css # Global styles
components/
βββ templates/ # 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, 15-minute stranded-resume sweep
βββ 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 # Worker entry (TanStack Start handler + Workflows + Cron + Queue + WebSocket auth)
βββ cached-pages.ts # CachedPages entrypoint: Workers Cache for public pages + purges
migrations_pg/
βββ *.sql # Postgres migrations (drizzle-kit)
tests/
βββ integration/ # Integration tests
βββ security/ # Security tests (IDOR, rate limits)
βββ helpers/ # Shared fixtures, including PGlite; tests use native Node WebCrypto
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
- User email: a separate best-effort
EMAILstep sends a dashboard link after terminal failure or cache expiry, only to verified Clerk account owners; no automatic email retries
- 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) |
*/15 * * * * |
Every 15 minutes | Fails resumes stuck in pending_claim/queued (over 15 min) or processing (over 90 min); the user can retry them from the dashboard |
Runs via worker/index.ts without self-fetch (avoids double billing).
Built-in templates in components/templates/ (metadata in THEME_METADATA, lib/templates/theme-ids.ts):
| Template | Category | Description |
|---|---|---|
| Academic CV | Professional | A scholarly CV with education first, dated appointments and numbered research outputs |
| Bento Grid | Modern | Flat colour tiles for your current role, a standout result and your full career |
| Blueprint | Creative | A cyanotype drawing sheet with grid paper, section callouts and a title block |
| Boardroom | Professional | A dark executive ledger with a pinned identity column and brass accents |
| Bold Corporate | Professional | Annual-report layout with a navy sidebar and condensed headings |
| Broadsheet | Professional | A newspaper front page: ruled columns, a masthead name and each role set as a story |
| Case File | Professional | A typed dossier in a manila folder, with each section filed as a lettered exhibit |
| Classic ATS | Professional | A printable one-column resume sheet that applicant tracking systems parse cleanly |
| Contact Sheet | Creative | Film-frame tiles on near-black with grease-pencil orange, built for photographers |
| DesignFolio | Creative | Swiss grid on cool grey with a cobalt name block. Project images lead the page. |
| DevTerminal | Developer | GitHub-style profile with pinned projects and a commit timeline |
| Gallery Wall | Creative | An exhibition wall with hung works on labels and a printed artist CV below |
| Glass Morphic | Modern | Frosted glass panes lit by one window of light, with a reeded-glass insert |
| Lesson Plan | Creative | A ruled planner page with binder holes, coloured subject tabs and a sticky note |
| Liner Notes | Creative | An album sleeve with the record sliding out and your jobs listed as Side A tracks |
| Link Stack | Modern | A link-in-bio page of full-width colour bars, ultramarine to peach, each one a link to tap or a role to open |
| Manuscript | Professional | A book spread with a title page, a table of contents and bylines as a bibliography |
| Media Kit | Modern | A creator's brand one-sheet: a fuchsia cover, a wordmark wall of who you've worked with and your real results |
| Midnight | Modern | Midnight blue night sky, Garamond headings and a gold star timeline |
| Minimalist Editorial (default) | Professional | Quiet single serif column with dates set in the margin |
| Neo Brutalist | Creative | Loud yellow poster with a giant name, hard shadows and a skills ticker |
| Open House | Creative | A real-estate listing sheet: your name on a hanging yard sign, a property-details table and roles filed as listings |
| Personal Best | Modern | A track meet: your name painted across the lanes, real marks on an LED board and roles as a results sheet |
| Pitch Deck | Modern | Your resume as a 16:9 founder deck: title slide, one-liner, traction and the ask |
| Quota | Professional | A sales resume with a closed-won board of the real figures from your highlights and a dated track record |
| Reel | Creative | A film-editing timeline with clip markers, timecode dates and a red playhead |
| Retro OS | Creative | A late-90s desktop where every section opens in its own bevelled window |
| Rounds | Professional | A nurse's ID badge on a lanyard, with licenses first and experience as a shift log |
| Scrapbook | Creative | Craft-paper desk with taped index cards, sticky-note skills and a polaroid portrait |
| Spotlight | Creative | A dark stage with a hard-edged followspot on your name and a theatre programme of credits |
| The Brief | Professional | A legal brief: a boxed caption header, roman-numeral sections and double-spaced serif paragraphs |
| The Menu | Creative | A bistro prix-fixe card on a gingham tablecloth, with roles priced in years along dotted leaders |
| Workspace | Modern | A clean document page with a cover, a properties table and collapsible sections |
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 the quality gate and repo traps, and CODING_STANDARDS.md for what review checks.
- 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/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.
- TanStack Start - Full-stack React framework on Vite
- 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.