DEV Community

Cover image for Link Previews for User-Generated Pages: Profiles, Moderation and the Cards You Didn't Plan For
Abid Ali
Abid Ali

Posted on

Link Previews for User-Generated Pages: Profiles, Moderation and the Cards You Didn't Plan For

If your app has public profiles, listings, portfolios or posts, every one of those pages is a link someone will paste into WhatsApp, LinkedIn or Slack. The card that appears is built from text and images your users typed in. That's the point of the feature, and it's also where things get uncomfortable: a display name that says "Official Support", a profile image swapped after it was approved, a private account whose old card keeps circulating, a username that changes hands.

Most link preview advice assumes the person writing the tags is the site owner. On a user-generated page, you're writing the rules and your users are writing the content. This post is the setup I use in Next.js (App Router) to keep those cards useful without letting them become a liability.

What actually goes wrong

When I review a platform with user pages, the same handful of problems come up:

  • Raw user URLs in og:image. The profile stores an image URL from anywhere, and the page puts it straight into the share tag. You don't control the file size, the content type, or whether the file at that address changes next week.
  • User text with no framing. The og:title is just the display name. Someone named "Bank Customer Care" gets a card that looks like it came from a bank, sitting under your domain.
  • One card for every state. Private, suspended, pending review and removed accounts all render the same metadata as a public one, because generateMetadata was written for the happy path.
  • Renamed handles. A user changes @studio-amna to @amna-designs. Old links break, or worse, someone else claims the old handle and inherits every link that was ever shared.
  • Takedowns that don't take. A moderator removes a profile, but the page, its metadata and its share image stay cached on your side for hours.

None of this shows up in a design review, because the designs use a nice sample profile. It shows up when a real link is shared.

Step 1: decide the card for every state

I start with a pure function that takes the stored profile and returns the card. No framework imports, so it's easy to test.

// lib/share-card.ts
export const SITE = "https://www.example.com";
export const SITE_NAME = "Example";

export type ProfileForShare = {
  id: string;
  handle: string;
  displayName: string;
  bio: string | null;
  avatarUrl: string | null;
  visibility: "public" | "unlisted" | "private";
  moderation: "approved" | "pending" | "removed";
  suspended: boolean;
  updatedAt: Date;
};

export type ShareCard = {
  kind: "user" | "generic";
  title: string;
  description: string;
  url: string;
  image: string;
  imageAlt: string;
  index: boolean;
};

export function shareCardFor(p: ProfileForShare): ShareCard {
  const url = `${SITE}/u/${p.handle}`;
  const generic = {
    kind: "generic" as const,
    url,
    image: `${SITE}/og/default.png`,
    imageAlt: `${SITE_NAME} logo`,
    index: false,
  };

  if (p.suspended || p.moderation === "removed") {
    return { ...generic, title: `Profile unavailable · ${SITE_NAME}`, description: "This profile isn't available." };
  }
  if (p.visibility === "private") {
    return { ...generic, title: `Private profile · ${SITE_NAME}`, description: "This profile is private." };
  }
  if (p.moderation === "pending") {
    return { ...generic, title: `New profile · ${SITE_NAME}`, description: "This profile is waiting for review." };
  }

  const name = cleanUserText(p.displayName, 60) || p.handle;
  const bio = p.bio ? cleanUserText(p.bio, 160) : "";
  return {
    kind: "user",
    url,
    title: `${name} (@${p.handle}) on ${SITE_NAME}`,
    description: bio || `See ${name}'s work on ${SITE_NAME}.`,
    image: `${SITE}/og/profile/${p.id}?v=${p.updatedAt.getTime()}`,
    imageAlt: `Profile card for ${name} on ${SITE_NAME}`,
    index: p.visibility === "public",
  };
}
Enter fullscreen mode Exit fullscreen mode

A few decisions are baked in here, and they're worth making on purpose:

  • Private and removed profiles get no user text at all. Not the name, not the avatar. If someone forwards an old link to a private account, the card shouldn't reveal who it belongs to.
  • Pending profiles get a generic card too. Otherwise the window between sign-up and review is exactly when a spam account's card looks its best.
  • Unlisted profiles get a full card but index: false. Unlisted usually means "anyone with the link", so the link is meant to be shared, just not found through search.
  • The title always includes the handle and the site name. The handle is unique and the display name isn't. Bank Customer Care (@xyz123) on Example reads very differently from Bank Customer Care.

Step 2: clean user text before it reaches a meta tag

React escapes attribute values for you, so this isn't about HTML injection. It's about what the text looks like on a card.

// lib/share-card.ts (continued)
export function cleanUserText(input: string, max: number): string {
  const flat = input
    .normalize("NFC")
    .replace(/[\u0000-\u001F\u007F-\u009F]/g, " ")        // control characters
    .replace(/[\u200B\u2060\uFEFF]/g, "")                 // invisible spacers
    .replace(/[\u202A-\u202E\u2066-\u2069]/g, "")         // bidi overrides and isolates
    .replace(/\s+/g, " ")
    .trim();

  const chars = Array.from(flat); // code points, so emoji surrogates aren't split
  return chars.length <= max ? flat : chars.slice(0, max - 1).join("").trimEnd() + "…";
}
Enter fullscreen mode Exit fullscreen mode

The bidi overrides are the ones people forget. They can make a name render in a different order than it's stored, which is a classic way to make text look like something it isn't. I keep zero-width joiners and non-joiners, because emoji sequences and scripts like Urdu and Persian rely on them. The code-point slice is a simple version; if your users write long names in languages with combining marks, a grapheme-aware clip with Intl.Segmenter is the better choice.

Step 3: wire it into generateMetadata

// app/u/[handle]/page.tsx
import type { Metadata } from "next";
import { notFound, permanentRedirect } from "next/navigation";
import { getProfileByHandle, findHandleRedirect } from "@/lib/profiles";
import { shareCardFor, SITE_NAME } from "@/lib/share-card";

type Props = { params: Promise<{ handle: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { handle } = await params;
  const profile = await getProfileByHandle(handle);
  if (!profile) return { title: `Profile not found · ${SITE_NAME}`, robots: { index: false } };

  const card = shareCardFor(profile);
  return {
    title: card.title,
    description: card.description,
    alternates: { canonical: card.url },
    robots: card.index ? undefined : { index: false, follow: false },
    openGraph: {
      type: "profile",
      siteName: SITE_NAME,
      title: card.title,
      description: card.description,
      url: card.url,
      images: [{ url: card.image, width: 1200, height: 630, alt: card.imageAlt }],
    },
    twitter: { card: "summary_large_image", title: card.title, description: card.description, images: [card.image] },
  };
}

export default async function ProfilePage({ params }: Props) {
  const { handle } = await params;
  const profile = await getProfileByHandle(handle);
  if (!profile) {
    const current = await findHandleRedirect(handle);
    if (current) permanentRedirect(`/u/${current}`);
    notFound();
  }
  // ...render the profile
}
Enter fullscreen mode Exit fullscreen mode

Wrap getProfileByHandle in React's cache() so the metadata and the page share one database read per request.

Step 4: the share image comes from your server, built from stored data

The card points at /og/profile/{id}, never at the user's own image URL. That route reads the profile again, applies the same rules, and draws the image.

// app/og/profile/[id]/route.tsx
import { ImageResponse } from "next/og";
import { getProfileById } from "@/lib/profiles";
import { shareCardFor, cleanUserText, SITE, SITE_NAME } from "@/lib/share-card";

const AVATAR_HOSTS = new Set(["uploads.example.com"]);

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const p = await getProfileById(id);
  const card = p ? shareCardFor(p) : null;
  if (!p || !card || card.kind === "generic") {
    return Response.redirect(`${SITE}/og/default.png`, 302);
  }

  const name = cleanUserText(p.displayName, 40) || p.handle;
  const avatar =
    p.avatarUrl && AVATAR_HOSTS.has(new URL(p.avatarUrl).hostname) ? p.avatarUrl : null;

  return new ImageResponse(
    (
      <div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center",
                    gap: 48, padding: 80, background: "#0f172a", color: "white" }}>
        {avatar && <img src={avatar} width={240} height={240} style={{ borderRadius: 120 }} />}
        <div style={{ display: "flex", flexDirection: "column" }}>
          <div style={{ fontSize: 64, fontWeight: 700 }}>{name}</div>
          <div style={{ fontSize: 34, opacity: 0.8 }}>@{p.handle} · {SITE_NAME}</div>
        </div>
      </div>
    ),
    { width: 1200, height: 630, headers: { "Cache-Control": "public, max-age=3600" } },
  );
}
Enter fullscreen mode Exit fullscreen mode

Why go to this trouble instead of using the uploaded avatar directly:

  • You control the file. Same size, same format, same weight every time. A user's 9 MB phone photo doesn't become the reason a crawler gives up on your card.
  • Avatars only come from your own storage. The allowlist means a profile can't point the image at a third-party server that logs every crawler request, or swap the file after review.
  • The route takes an ID, not text. If your image endpoint accepts ?title=..., anyone can generate an image with any words on it under your domain. An ID-only route can only draw what's in your database.
  • The ?v= changes when the profile changes. Platforms tend to cache by image URL, so a new version gets a fresh fetch. The route itself ignores v.

I keep max-age modest on purpose. The versioned URL would allow long caching, but if a profile is removed, I don't want its old image sitting in my own CDN for a week.

Step 5: make takedowns and renames take effect

When a moderator acts, invalidate the page so the next crawler request gets the generic card:

// lib/moderation.ts
import { revalidatePath } from "next/cache";

export async function removeProfile(id: string) {
  const p = await db.profile.update({
    where: { id },
    data: { moderation: "removed", updatedAt: new Date() },
  });
  revalidatePath(`/u/${p.handle}`);
}
Enter fullscreen mode Exit fullscreen mode

Be honest with your moderators about the limits: cards already posted in chats are copies held by those apps. You can't recall them. What you can control is what every future fetch gets.

For renames, store old handles in a table with the profile ID, redirect them with a 308 for a grace period, and don't let anyone else register an old handle during that time. Otherwise the new owner inherits every link that was shared under the old name, and with it, other people's audiences.

Step 6: test the rules, then check real cards

Because shareCardFor is pure, the important cases are cheap to test with node:test:

import { test } from "node:test";
import assert from "node:assert/strict";
import { shareCardFor, cleanUserText } from "./share-card.mjs";

const base = { id: "p1", handle: "amna", displayName: "Amna", bio: "Logo design", avatarUrl: null,
  visibility: "public", moderation: "approved", suspended: false, updatedAt: new Date(0) };

test("private profiles leak no user text", () => {
  const c = shareCardFor({ ...base, visibility: "private" });
  assert.equal(c.kind, "generic");
  assert.ok(!c.title.includes("Amna") && !c.description.includes("Logo"));
});

test("titles carry the handle", () => {
  const c = shareCardFor({ ...base, displayName: "Bank Customer Care" });
  assert.equal(c.title, "Bank Customer Care (@amna) on Example");
});

test("bidi overrides are stripped", () => {
  assert.equal(cleanUserText("abc\u202Edef", 60), "abcdef");
});
Enter fullscreen mode Exit fullscreen mode

Then check a few real pages from outside. I paste one public, one unlisted and one private profile into the Open Graph preview on OG Scope, a free checker I built that fetches the page server-side without running JavaScript. It shows estimated cards for several apps plus the raw tag values, so it's quick to confirm the private profile really returns the generic title and image, and that the robots tag differs between public and unlisted. The cards are estimates, not screenshots from the platforms, but they read the same HTML the crawlers get.

The checklist

  • One function decides the card for every account state, and it's tested.
  • Private, pending, suspended and removed accounts get a generic card with no user text.
  • Titles include the handle and site name, not just the display name.
  • User text is normalised and stripped of control and bidi characters.
  • Share images come from your own route, built from stored data, with avatars from your own storage only.
  • Moderation actions revalidate the page; old handles redirect and stay reserved for a while.

User pages are some of the most shared links a platform has. Spending a day on their cards is cheap compared with explaining why a removed account still looks fine in everyone's chats.


I'm Abid Ali, a freelance full stack developer in Karachi working with TypeScript, Next.js, React and Node.js. I built OG Scope, a free link preview checker.

Top comments (0)