Personal meeting link shortener. Turn long Zoom, Google Meet, or Teams URLs into clean, branded short links β with proper OG preview cards in WhatsApp, Slack, iMessage, and X.
https://us06web.zoom.us/j/84581102465?pwd=JM9zb3VPMZRDbDjP24GUT3LVJC9TRi.1
β
https://meet.yourdomain.com/dcda3f17
Built for meet.paulfleury.com β fork it and make it yours in 10 minutes.
I use Zoom and cal.com for every meeting. The problem: Zoom URLs are hideous 80-character strings with session tokens. When you drop one into WhatsApp or send it over email, it looks like spam. cal.com gives you a booking page, not a direct join link.
What I wanted:
- A root URL (
meet.paulfleury.com) that forwards people to my calendar - A tool to shorten any one-off meeting link into a clean
meet.paulfleury.com/xxxxxxxxURL - That short URL shows a proper preview card (title, description, image) when shared in any chat app
- The redirect is instant β no spinner, no loading screen, just gone
This is that tool.
| URL | What happens |
|---|---|
meet.paulfleury.com |
Instant redirect to cal.com/paulfxyz |
meet.paulfleury.com/link |
Link creator UI |
meet.paulfleury.com/dcda3f17 |
Instant redirect to the original Zoom URL |
Understanding the architecture helps if you're self-hosting or debugging.
Browser hits meet.paulfleury.com/dcda3f17
β
βΌ
nginx (SiteGround reverse proxy)
β
βΌ
Apache + mod_rewrite (.htaccess)
β
ββ / β index.html (JS redirects to cal.com)
ββ /link β app.html (React SPA for the creator UI)
ββ /dcda3f17 β dcda3f17.html β static HTML written by api.php
ββ /api/links β api.php?action=create
ββ /api/links/hash β api.php?action=get&hash=dcda3f17
When you create a short link, api.php does two things:
- Inserts a row into SQLite β stores hash, original URL, title, timestamp
- Writes a static
.htmlfile to disk β e.g.dcda3f17.html
That static file contains:
- Full Open Graph + Twitter Card meta tags (hardcoded title, description, image)
<meta http-equiv="refresh" content="0;url=https://zoom.us/...">β zero-delay redirect- A plain
<a href>fallback for accessibility
No PHP runs when the short link is visited. No JavaScript either. It's pure HTML served directly by Apache. This is why the redirect works in every in-app browser, every bot crawler, and every context where JS is blocked or slow.
A PHP redirect (header("Location: ...")) would work for browsers, but WhatsApp, Slack, and Telegram's crawlers would see an empty page with no meta tags β because the OG tags would need to be in the HTML response, not in a redirect header.
With static HTML, every request β whether from a human browser, WhatsApp's preview bot, or Slack's link unfurler β gets back a real HTML page with the correct OG tags baked in.
The React SPA (used for /link) uses hash-based routing (/#/link, /#/:hash) via wouter's useHashLocation. This is intentional: the app is deployed to shared Apache hosting without Node.js. Path-based routing (e.g. /link serving the SPA directly) requires the server to return index.html for every path β which conflicts with the api.php routes and the static dcda3f17.html files. Hash routing sidesteps this entirely: Apache doesn't care what comes after #, so the SPA handles it client-side.
The short links themselves (/dcda3f17) are NOT hash-routed β they're served as real static HTML files by Apache. This is the key distinction:
| URL | Served by | Contains |
|---|---|---|
/#/link |
React SPA (hash route) | Link creator component |
/dcda3f17 |
Apache (static file) | Hardcoded OG + meta refresh |
Building this looked simple. It wasn't. Here's every real issue we ran into, in order.
What happened: Visiting /link sent you straight to cal.com instead of showing the creator UI.
Why: index.html had a JS snippet that fired on any path without a hash:
if (window.location.hash === '' || window.location.hash === '#') {
window.location.replace('https://cal.com/paulfxyz');
}Since /link has no hash, it triggered the cal.com redirect before React even loaded.
Fix: Apache needs to intercept /link before index.html is served. Added a dedicated .htaccess rule:
RewriteRule ^link/?$ app.html [L]app.html is a separate SPA shell (same React bundle, no cal.com redirect script). The root index.html keeps its JS redirect for bare / visits.
What happened: After fixing /link, the React SPA would generate a URL like /#/dcda3f17. But if you opened that URL cold (e.g. pasted it into a new tab), it went to cal.com.
Why: The JS check in index.html only looked at window.location.hash === '' β but /#/dcda3f17 has a non-empty hash. The real bug was that the check wasn't distinguishing between "no hash" and "hash present":
// Bug: this fires even when hash is '#/dcda3f17'
if (!window.location.hash) { ... }Fix: Updated the condition to only redirect when hash is truly empty or just #:
if (window.location.hash === '' || window.location.hash === '#') {
window.location.replace('https://cal.com/paulfxyz');
}If a hash is present, the SPA loads and handles routing internally.
What happened: Tapping meet.paulfleury.com/dcda3f17 in WhatsApp opened the link and immediately redirected to cal.com, not Zoom. The same URL worked fine in Safari or Chrome.
Root cause: The .htaccess on the server was an old version β it was missing the hash rewrite rule entirely. The uploaded file was correct locally but the server had never received the update (FTP transfer had silently failed earlier). Without the rewrite rule, Apache fell through to index.html, which redirected to cal.com.
Additionally, we discovered that an extensionless file named dcda3f17 (no .html) had been written to disk by an earlier version of api.php. Apache's -f (file exists) check was matching this extensionless file before the rewrite rule ran, then serving it as an unknown binary type β which also fell through to index.html. A double failure.
Fix β two-part:
- Deleted all extensionless hash files from the server
- Fixed
api.phpto only ever write{hash}.html, never a bare{hash}:// Before (bug): file_put_contents(__DIR__ . "/{$safeHash}", $html); // After (fixed): file_put_contents(__DIR__ . "/{$safeHash}.html", $html);
- Re-uploaded the correct
.htaccesswith the hash rewrite rule placed before the-fpassthrough:# Hash rewrite MUST come before -f check RewriteRule ^([a-f0-9]{8})/?$ $1.html [L] # THEN check for existing files RewriteCond %{REQUEST_FILENAME} -f RewriteRule ^ - [L]
Lesson: Rule order in .htaccess is load-bearing. If the -f check (does this file exist?) runs before the hash rewrite, Apache looks for a file named dcda3f17 with no extension. When that extensionless file existed, it matched the -f check and got served as garbage. When it didn't exist, the file check failed and fell through to index.html. Either way: wrong result. The hash rewrite must come first.
What happened: Sharing meet.paulfleury.com/dcda3f17 in WhatsApp showed "Meeting Paul Fleury" (the generic site OG) instead of the per-link title like "Call with Agnes".
Expected behavior: Each short link has a custom og:title and og:description baked into its static HTML file. WhatsApp's crawler should read those.
Root cause: The hosting provider (SiteGround) runs nginx as a reverse proxy in front of Apache, with active bot protection. Any HTTP request from a non-browser IP β including WhatsApp's link preview crawler β gets intercepted and returned as HTTP 202 with a captcha redirect page. The crawler never reaches Apache. It never sees dcda3f17.html. It falls back to the cached root OG data.
Confirmed by:
curl -v -A "WhatsApp/2.0" https://meet.paulfleury.com/dcda3f17
# Returns: HTTP/2 202 with sg-captcha: challenge header
# Body: captcha redirect page, not dcda3f17.htmlWorkarounds explored:
- Whitelist WhatsApp crawler IPs β not viable (Meta rotates IPs constantly)
- Disable bot protection for the subdomain β would work, but risks abuse on shared hosting
- Cloudflare Worker in front of the subdomain β cleanest solution, serves static HTML to crawlers from the edge without touching SiteGround
Current state: The per-link OG tags are correct in the HTML files. The redirect works perfectly in all browsers and in-app WebViews (WhatsApp in-app browser, Telegram browser, etc.) β the issue is only with link preview generation, where the crawler itself gets blocked. The generic "Meeting Paul Fleury" preview is still meaningful and accurate for most use cases.
meet/
βββ frontend/ # React source (Vite + TypeScript)
β βββ client/
β β βββ index.html # SPA shell for /link
β β βββ src/
β β βββ App.tsx # Hash router (wouter + useHashLocation)
β β βββ pages/
β β β βββ link-creator.tsx # /link β shortener UI
β β β βββ meeting-redirect.tsx # /#/:hash β SPA fallback redirect
β β β βββ redirect.tsx # /#/ β cal.com redirect
β β βββ lib/
β β βββ queryClient.ts # TanStack Query + apiRequest helper
β βββ shared/
β βββ schema.ts # Shared Link type (Drizzle + Zod)
β
βββ public_html/ # Files deployed to your web root
βββ .htaccess # Apache rewrite rules (critical β read the comments)
βββ index.html # Root shell: cal.com redirect if no hash
βββ app.html # /link SPA shell (no cal.com redirect)
βββ api.php # REST API: POST creates link, GET fetches it
βββ data/
β βββ links.db # SQLite β auto-created on first request
βββ assets/ # Built JS + CSS (output of npm run build)
βββ og-image.png # 1200Γ630 OG image β replace with your own
βββ favicon.ico / favicon-*.png # Multi-size favicons
βββ apple-touch-icon.png # 180Γ180 iOS icon
βββ icon-192.png / icon-512.png # PWA icons
βββ site.webmanifest # PWA manifest
| Layer | Technology | Why |
|---|---|---|
| Frontend | React 18 + Vite + TypeScript | Fast dev, type-safe, great DX |
| UI | Tailwind CSS v3 + shadcn/ui | Consistent, unstyled-first, no bloat |
| Routing | wouter + useHashLocation | Hash-based routing survives Apache passthrough |
| Data fetching | TanStack Query v5 | Caching, loading states, mutations |
| Backend | PHP 8+ | Runs on any shared host, zero config |
| Database | SQLite3 | Single file, no server, perfect for personal use |
| Hosting | Apache + mod_rewrite | .htaccess handles all URL mapping |
- Instant redirect β
<meta http-equiv="refresh" content="0;url=...">, fires before paint, zero JS required - No-JS redirect β works in every in-app browser (WhatsApp, Telegram, WeChat), even with scripting disabled
- Static OG pages β each link gets its own
.htmlfile with hardcoded OG + Twitter Card tags - Clean URLs β
/dcda3f17(no#fragment), copyable and shareable as-is - Zero-setup database β SQLite auto-creates on first request; no migrations, no admin panel
- Shared hosting compatible β PHP + Apache only; no Node.js, Docker, or server access needed in production
- PWA manifest + icons β installable on iOS and Android home screen
- MIT licensed β fork it, white-label it, ship it
-
OG crawler blocking on SiteGround β SiteGround's nginx bot protection intercepts social crawlers before they reach the static HTML files. Per-link OG tags work correctly in the HTML but may not appear in WhatsApp/Slack link previews on SiteGround-hosted instances. Fix: disable bot protection for the subdomain, or put Cloudflare in front.
-
No authentication on
/linkβ anyone who finds/linkcan create short links. Fine for personal use; add HTTP Basic Auth in.htaccessif you want to restrict it. -
No link management UI β there's no dashboard to view, delete, or edit existing links. Links live in
links.db; you can inspect it with any SQLite browser (DB Browser for SQLite is great). -
8-char hash space β 4 random bytes β 8 hex chars β ~4.3 billion combinations. Collision probability at 1 million links is ~0.01%. Plenty for personal use.
-
Generated HTML files accumulate β every created link writes a
{hash}.htmlfile to disk. There's no cleanup mechanism. For personal use this is fine (files are tiny ~2KB each). For high-volume use, add a cron to prune old files.
See INSTALL.md for the full self-hosting guide β covers build, FTP deploy, domain config, permissions, and troubleshooting.
Quick version:
git clone https://github.com/paulfxyz/meet.git
cd meet/frontend
npm install
npm run build
# Upload public_html/ to your Apache web root
# Point your domain DNS β doneMIT β see LICENSE.
Built by Paul Fleury Β· @paulfxyz