Overview
Socl.gg started as a link-in-bio product, but most of the engineering work was not the link list.
The public page had to be fast, customizable, and shareable. The dashboard had to let users build a page out of ordered components, schedule links, upload media, connect Discord and Spotify, see analytics, manage billing, and in the business case, manage other profiles inside an organization.
That created an interesting split.
The frontend was a Next.js app where public profiles were server-rendered, then hydrated with the pieces that were too live or too external to put in the first payload. The backend was a Bun/Elysia API backed by PostgreSQL and Prisma, with generated TypeBox schemas, cookie sessions, Stripe webhooks, S3-compatible uploads, Redis-backed realtime integrations, and a small analytics system designed to avoid counting the same person over and over.
The weird parts were where the product looked simple from the outside but needed careful boundaries underneath: plan limits had to apply to both the dashboard and public profile reads, analytics had to reject fake events without depending only on IP addresses, and realtime integrations had to feel live without making the page render depend on third-party APIs.
Sections
Public Profiles
A server-rendered page shell with live client-side integrations.
What I Built
The core object in Socl.gg is a bio page. A user owns one bio, and the bio owns the visible surface: avatar, banner, profile metadata, profile buttons, ordered components, badges, integrations, views, link clicks, and customization settings.
Components were deliberately generic. A link, a heading, an image, a link search input, a Spotify embed, a YouTube embed, and a Discord/Spotify integration all lived in the same ordered component list. That made the dashboard simpler because drag-and-drop could reorder one list instead of several feature-specific lists.
The public API for a profile did more than fetch a user by username. It also calculated the user’s real plan, filtered out components scheduled for the future, capped the number of components according to the plan, sorted them by position, hid roles when badge display was disabled, and rejected banned users.
That meant the dashboard was not the only enforcement point. Even if a user had old data from a previous plan, the public profile response still reflected what the current plan allowed.
const user = await prisma.user.findUnique({
where: { username },
select: {
username: true,
roles: true,
badges: { where: { visible: true } },
plan: true,
planExpires: true,
bio: {
select: {
customization: true,
profileButtons: true,
components: {
where: { visibleOn: { lte: new Date() } },
include: { integration: true },
orderBy: { position: "asc" },
take: maxComponents > 0 ? maxComponents : 1000,
},
},
},
},
});const user = await prisma.user.findUnique({
where: { username },
select: {
username: true,
roles: true,
badges: { where: { visible: true } },
plan: true,
planExpires: true,
bio: {
select: {
customization: true,
profileButtons: true,
components: {
where: { visibleOn: { lte: new Date() } },
include: { integration: true },
orderBy: { position: "asc" },
take: maxComponents > 0 ? maxComponents : 1000,
},
},
},
},
});On the frontend, the profile page used server-side props for the stable page data. That kept the first render shareable and indexable. Then the browser connected to Discord and Spotify only if those integration components existed.
The profile page was not waiting on “what song is playing right now” before it could render the user’s links.
The Component Model
The dashboard had to make editing feel immediate, but the database needed a compact model that could survive feature growth.
So components shared a table with nullable fields for type-specific data: text value and text styling for headings, URL and title for links and embeds, image sizing for images, and an optional integration relation for dynamic blocks. It is not the purest schema, but it worked well for a builder product where every block needs the same ownership, ordering, visibility, and plan enforcement.
The frontend reflected that same shape. A single ComponentDisplay handled the branch:
if (item.type === ComponentType.LINK) {
return <Link href={item.url!}>{item.title}</Link>;
}
if (item.type === ComponentType.TEXT) {
return <span>{item.textValue}</span>;
}
if (item.integration?.type === IntegrationType.DISCORD) {
return <DiscordIntegration discordData={discordData} user={user} />;
}if (item.type === ComponentType.LINK) {
return <Link href={item.url!}>{item.title}</Link>;
}
if (item.type === ComponentType.TEXT) {
return <span>{item.textValue}</span>;
}
if (item.integration?.type === IntegrationType.DISCORD) {
return <DiscordIntegration discordData={discordData} user={user} />;
}The useful tradeoff was that adding a new page block usually meant adding one backend route, one dashboard editor, and one display branch. It did not require redesigning how profile pages were assembled.
Analytics
Counting views and clicks without trusting a plain request.
Analytics Without Easy Spam
The analytics problem was simple to describe: users should see profile views, link clicks, profile-button clicks, country, device category, referrer, and click-through rates.
The hard part was avoiding fake data.
Counting by IP address alone is weak. Counting every request is worse. The workaround was a small client fingerprint flow. The browser generated a fingerprint payload, signed it, sent it to the backend, and the backend re-encrypted the verified payload into a one-day cookie. Later view and click events sent that encrypted payload instead of a raw browser object.
On the event endpoint, the backend decrypted the payload, required the real marker, pulled together the IP, audio data, OS data, user-agent data, WebGL data, canvas data, and a common image hash, then generated an ssdeep fuzzy hash.
For each incoming view or click, recent events from the last 24 hours were compared in two ways:
- exact match on the common image hash
- fuzzy similarity against the stored identity hash
If the similarity was too high, the event was rejected as a duplicate.
const compareData = {
plainIp,
...fingerprint.audio,
...fingerprint.os,
...fingerprint.system.userAgent,
...fingerprint.system.browser,
...fingerprint.webgl,
...fingerprint.canvas,
};
const identity = ssdeep.digest(JSON.stringify(compareData));
for (const view of recentViews) {
if (view.commonImageHash === compareData.commonImageHash) {
return duplicateView();
}
if (ssdeep.similarity(view.viewerIdentity, identity) > 85) {
return duplicateView();
}
}const compareData = {
plainIp,
...fingerprint.audio,
...fingerprint.os,
...fingerprint.system.userAgent,
...fingerprint.system.browser,
...fingerprint.webgl,
...fingerprint.canvas,
};
const identity = ssdeep.digest(JSON.stringify(compareData));
for (const view of recentViews) {
if (view.commonImageHash === compareData.commonImageHash) {
return duplicateView();
}
if (ssdeep.similarity(view.viewerIdentity, identity) > 85) {
return duplicateView();
}
}It was not trying to be a perfect fraud system. It was trying to make the obvious abuse paths expensive enough that analytics stayed useful for normal users.
The reporting route then returned sanitized analytics data instead of dumping the raw fingerprint identity back to the client. The dashboard got timestamps, country, device type, referrer, link IDs, profile-button IDs, and CTR values. The raw matching material stayed server-side.
Realtime Integrations
Discord and Spotify as optional live blocks, not render blockers.
WebSockets on the Profile Page
Discord and Spotify were treated differently from normal components because they represented live state.
For Discord, the backend opened a WebSocket for the profile username, looked up the linked Discord account, subscribed to a Redis channel for that Discord ID, sent the cached state immediately, and then forwarded later pub/sub messages to the browser.
For Spotify, the profile WebSocket subscribed to a connection-specific Redis response channel and published refresh requests on an interval. If there was cached lastData, it sent that first so the profile did not start empty while the worker refreshed the currently-playing state.
The important part was the boundary: public profile data came from PostgreSQL, while live integration state moved through Redis and WebSockets.
subRedis.subscribe(`spotify:connection:response:${connection.id}`);
if (connection.lastData) {
ws.send(connection.lastData);
}
const refresh = setInterval(() => {
pubRedis.publish(
"spotify:connection:request",
JSON.stringify({ userId }),
);
}, 15350);subRedis.subscribe(`spotify:connection:response:${connection.id}`);
if (connection.lastData) {
ws.send(connection.lastData);
}
const refresh = setInterval(() => {
pubRedis.publish(
"spotify:connection:request",
JSON.stringify({ userId }),
);
}, 15350);That kept third-party state out of the main page query. A failed Spotify refresh could make one block stale, but it did not break the profile.
Plans
Feature flags, limits, and billing state lived in one product map.
Plans as Product Rules
Socl.gg had free and paid plans, but the plan logic was not just “show a billing page.”
Plans controlled maximum component count, analytics retention windows, cloning, organization access, custom domains, link scheduling, image components, animated media, custom metadata, branding removal, link search, and several customization controls.
Those rules lived in a shared planFeatures map. Routes used it before creating paid features, analytics used it before returning longer date ranges, uploads used it before allowing animated avatars and banners, and the public profile route used it before returning too many components.
const features = planFeatures[getRealPlan(user.plan, user.planExpires)];
if (features.analyticsPeriod > 0 && days > features.analyticsPeriod) {
return paymentRequired("Upgrade your plan to view analytics for longer periods");
}
if (isAnimated && !features.customization.animatedAvatar) {
return paymentRequired("Upgrade your plan to use animated avatars");
}const features = planFeatures[getRealPlan(user.plan, user.planExpires)];
if (features.analyticsPeriod > 0 && days > features.analyticsPeriod) {
return paymentRequired("Upgrade your plan to view analytics for longer periods");
}
if (isAnimated && !features.customization.animatedAvatar) {
return paymentRequired("Upgrade your plan to use animated avatars");
}Stripe webhooks updated the plan and expiration date from subscription events. Premium subscriptions also pushed a verified role, and cancellation removed it again.
The workaround here was mostly defensive: billing events are asynchronous, dashboard state can get stale, and data created while subscribed can outlive the subscription. The product rules had to be checked wherever the feature was actually consumed.
Organizations
Business profiles needed managed identities, not shared passwords.
Organizations and Subusers
The business plan added a second shape to the product: a real user could own or administer an organization, and that organization could create subusers.
Subusers were actual user records with bios, but they were marked as isSubUser, given disabled login credentials, attached to the organization, and granted a business-level plan window. The owning organization could then manage those profiles without handing around passwords.
The backend derived two session contexts from cookies: a primary session and an optional subuser session. Most dashboard routes could choose subUser ? subUser.id : user.id, which let the same editing routes work for a user’s own page and an organization-managed page.
That was the practical workaround. Instead of building an entirely separate “managed profile” domain model, subusers reused the existing profile, component, analytics, and customization system while organization membership controlled who could operate on them.
Uploads
Direct uploads, plan checks, content hashes, and boring cache invalidation.
Media Uploads
Avatars and banners used presigned URLs for S3-compatible storage. The backend validated the extension, checked whether animated media was allowed for the user’s plan, included the SHA-256 checksum in the object command, and returned a signed upload URL.
After the browser uploaded the object, a separate update route changed the bio URL to the stable object key plus a timestamp query string.
const key = `avatars/${user.id}.${ext}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: `image/${ext}`,
ChecksumSHA256: hash,
});
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });const key = `avatars/${user.id}.${ext}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: `image/${ext}`,
ChecksumSHA256: hash,
});
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });The timestamp query string was small, but necessary. Users expect an avatar replacement to appear immediately, and object/CDN/browser caches do not care that the file contents changed if the visible URL did not.
Custom Domains
Custom domains were another paid feature with a simple user-facing promise and a less simple backend check.
A user could add a domain with allowed profile paths. The backend resolved the domain’s A record and compared it to the configured Socl.gg custom-domain IP. If the DNS record matched, the domain moved to ACTIVE; otherwise it stayed pending.
This kept domain ownership verification inside the application flow instead of requiring a manual support step.
Scope
The product was small on the surface and broad underneath.
What Made It Interesting
Socl.gg is the kind of project where the first version sounds like a weekend build: put links on a page, make them editable, add login.
The real work was in the product edges:
- public profiles had to stay fast while supporting live third-party blocks
- analytics had to be useful without accepting every request as a real view
- plan rules had to be enforced on creation, reporting, uploads, and public reads
- organizations needed managed profiles without a second profile system
- media uploads needed to bypass the API server but still respect product rules
- custom domains needed automated verification
Most of the implementation was TypeScript application code, but the interesting part was keeping those boundaries boring enough that the product could keep growing.