Deep Links & Share Links — Mobile Integration Guide
Every link Clustr hands out — from the share sheet, from a push notification, from a chat message — points at one of two schemes for the same screens:
https://letsclustr.com/posts/:id ksn://posts/:id
https://letsclustr.com/users/:username ksn://users/:username
Same paths, different scheme. Write one parser, not two. This doc is the contract for the React Native client: what the URLs mean, what to configure on each platform, how to test before any of it is verified, and what we still need from you.
What's new
- 2026-09-10 — Housekeeping only: the link site now runs on Firebase Hosting instead of
Cloudflare Pages. Nothing you build changes — same URLs, same
ksn://grammar, same previews. The only visible difference is in §9: a pre-domain test deploy shows up on a*.web.apphostname, not*.pages.dev, and verifying it still proves nothing aboutletsclustr.com. - 2026-09-09 — First version. Share URLs now live at
letsclustr.com/posts/:idand/users/:username(they used to be specced as/p/:id— that shape is gone). Profiles are shareable via a newGET /api/users/:username/share. Nothing here is verified yet: send us the bundle ids and signing fingerprints in §8 and Universal / App Links start working.
Short version
GET /api/users/:username/share → { url } (new)
POST /api/posts/:id/share → { url, shareCode, channel } (unchanged, new URL shape)
- Never build a link yourself. Ask the API — the base URL differs per environment.
- Route
https://letsclustr.com/<path>andksn://<path>through the same handler. - Strip and ignore
?ref=. Keep?commentId=. - Logged out on arrival? Stash the target, log in, then navigate.
1 — The URL grammar
| Screen | HTTPS | Custom scheme | Where the link comes from |
|---|---|---|---|
| Post detail | letsclustr.com/posts/:postId | ksn://posts/:postId | POST /api/posts/:id/share, notifications |
| Post, scrolled to a comment | letsclustr.com/posts/:postId?commentId=:id | ksn://posts/:postId?commentId=:id | comment + reply notifications |
| Profile | letsclustr.com/users/:username | ksn://users/:username | GET /api/users/:username/share, follow notifications |
| Conversation | — | ksn://chat/:conversationId?messageId=:id | chat notifications |
| Event | — | ksn://events/:id | event notifications |
Chat and events are in-app only for now. There is no
letsclustr.com/chat/... — the domain does not serve that path, the association
files do not claim it, and your intent-filter should not list it. When group
links land, all four move together.
?ref= is opaque
POST /api/posts/:id/share appends ?ref=<10 chars> for share attribution:
https://letsclustr.com/posts/019f45c6-…?ref=S6kvTbrHCR
Treat it as noise. Don't parse it, don't branch on it, don't send it anywhere.
"/posts/019f45c6-…?ref=S6kvTbrHCR" means postId = "019f45c6-…", full stop.
Nothing reads ref today; if that changes you'll get a new endpoint, not new
parsing rules.
Ids and usernames
postId is a UUIDv7. username matches [a-zA-Z0-9_] and is case-insensitive
on the server, but always arrives in canonical casing — the API builds the link
from the stored username, so /users/BOB will never be minted even if someone
types it.
2 — Getting a link
Profile
GET /api/users/:username/share
Authorization: Bearer <accessToken>
{ "success": true, "data": { "url": "https://letsclustr.com/users/anya_07" } }
| Code | When |
|---|---|
404 | No such user, account deactivated, blocked in either direction, or moderation-hidden |
401 | Missing/expired token |
A private profile is shareable and returns 200 — the recipient lands on the
locked profile with a follow button, which is the point. Only "this account
should not be discoverable" cases 404.
Post
Unchanged, see mobile-posts-guide §18. Only the
URL shape moved: /posts/:id, not /p/:id.
Always call the endpoint rather than string-building
https://letsclustr.com/posts/${id} on the client. The base URL is server
config; a staging build must not hand out production links.
3 — Platform configuration
iOS — Associated Domains
Add to the entitlement:
applinks:letsclustr.com
Handle incoming links in application(_:continue:restorationHandler:) (or the
RN Linking equivalent). iOS fetches
https://letsclustr.com/.well-known/apple-app-site-association at install time;
if it doesn't match your Team ID + bundle id, links open Safari instead — see §7.
Android — App Links
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="letsclustr.com" />
<data android:pathPrefix="/posts" />
<data android:pathPrefix="/users" />
</intent-filter>
Keep the existing ksn:// filter as a separate <intent-filter> — it is what
notifications and local development use, and it needs no verification.
4 — Parsing
One function, both schemes. The path is identical, so strip the scheme + host and switch on the first segment:
// letsclustr.com/posts/abc?commentId=x -> { kind: "post", id: "abc", commentId: "x" }
// ksn://posts/abc?commentId=x -> the same
export function parseDeepLink(raw: string) {
const u = new URL(raw);
const [head, id] = u.pathname.replace(/^\/+/, "").split("/");
const commentId = u.searchParams.get("commentId") ?? undefined;
switch (head) {
case "posts": return id ? { kind: "post", id, commentId } : null;
case "users": return id ? { kind: "profile", username: id } : null;
case "chat": return id ? { kind: "conversation", id,
messageId: u.searchParams.get("messageId") ?? undefined } : null;
case "events": return id ? { kind: "event", id } : null;
default: return null; // unknown → open the app at home, never crash
}
}
ksn://posts/abc parses as host posts, path /abc in some URL
implementations — RN's included. Normalise before splitting, or match on the raw
string. An unrecognised link must land the user on the home screen, not an error.
5 — Auth state on arrival
Three cases, and the middle one is the one that gets forgotten:
| State | Behaviour |
|---|---|
| Installed, logged in | Navigate straight to the target. |
| Installed, logged out | Stash the parsed target, run the normal login flow, then navigate to it. |
| Not installed | The OS opens the web fallback page (§6). |
The logged-out case is a single variable — pendingDeepLink, consumed once
after the first successful authenticated render — and it rescues the most common
real-world path, because the person receiving a shared link is often the one who
signed out months ago. Clear it on logout and on consumption so a stale target
can't fire later.
This is not deferred deep linking (install → land on the shared post). That is deliberately out of scope: a fresh install lands on home.
6 — What the web fallback page does
When the app isn't installed, the URL opens letsclustr.com in a browser. That
page is static and deliberately dumb:
- An Open in Clustr button (fires the
ksn://equivalent, in case the app is installed but the domain isn't verified yet). - App Store and Play Store buttons.
- No automatic redirect. Nothing bounces to the store on its own — that misbehaves for desktop visitors, crawlers and preview bots.
It never shows the post or the profile. It cannot: it's a static file with no API access.
7 — What link previews show
When a Clustr link is pasted into WhatsApp, Instagram, iMessage or anywhere else that unfurls URLs, the metadata served is identical for every link:
Clustr — Someone shared this with you. Open it in the Clustr app.
No post title, no body text, no photo, no username, no avatar. Clustr is a platform for minors and those crawlers cache what they fetch indefinitely, so nothing about the linked content leaves the app. Don't build UI that promises the recipient a rich preview, and don't file it as a bug.
The in-app share sheet is different: GET /api/posts/:id/share/preview
returns the real title, description and image, auth-gated, for prefilling the OS
share sheet. That data never reaches the web page.
8 — What we need from you
Until these land, the association files carry REPLACE_* placeholders. In that
state the fallback page and link previews work and native app association does
not work at all — an unverified file is inert, not broken. If you test today
and links open Safari or Chrome instead of the app, that is the expected result,
not a bug to chase.
| Value | Used in |
|---|---|
| Apple Team ID | apple-app-site-association |
| iOS bundle id | apple-app-site-association |
| App Store id | store button + iOS smart banner |
| Android package name | assetlinks.json + store button |
| Release SHA-256 fingerprint | assetlinks.json |
| Play App Signing SHA-256 fingerprint | assetlinks.json |
| Debug SHA-256 fingerprint | assetlinks.json (so debug builds verify too) |
9 — Testing
Locally, today
Universal Links and Android App Links cannot work against localhost — both
require HTTPS on a domain serving a verified association file. So local link
testing uses ksn://, which needs no domain, no TLS and no verification:
xcrun simctl openurl booted "ksn://posts/019f45c6-0000-7000-8000-000000000000"
xcrun simctl openurl booted "ksn://users/anya_07"
adb shell am start -a android.intent.action.VIEW -d "ksn://posts/019f45c6-…"
Exercise the logged-out path too — sign out first, then fire the same command.
HTTPS, once identifiers are in
# should open the app, not the browser
adb shell am start -a android.intent.action.VIEW -d "https://letsclustr.com/posts/<id>"
adb shell pm verify-app-links --re-verify <package>
adb shell pm get-app-links <package> # expect: verified
On iOS, tap a link from Notes or Messages — not from Safari's address bar, which deliberately bypasses Universal Links. Apple's AASA validator reads the production domain.
A green check on any
*.web.apppreview hostname means nothing. Association files are per-domain; onlyletsclustr.comcounts.
10 — Checklist
-
applinks:letsclustr.comentitlement (iOS) -
autoVerifyintent-filter for/posts+/users(Android) -
ksn://intent-filter kept alongside it - One
parseDeepLinkhandling both schemes - Unknown path → home screen, no crash
-
?ref=ignored,?commentId=honoured -
pendingDeepLinksurvives login, cleared on logout - Share button calls the API for the URL instead of building it
- Identifiers from §8 sent to backend