DEV Community

Cover image for Link Previews for Pages Behind a Login: What Crawlers See on Dashboards, Invite Links and Shared Docs
Abid Ali
Abid Ali

Posted on

Link Previews for Pages Behind a Login: What Crawlers See on Dashboards, Invite Links and Shared Docs

Someone on your team pastes https://app.example.com/projects/482/settings into Slack. The unfurl says "Sign in · Example" with your marketing hero image. Someone else sends a workspace invite link on WhatsApp and the card is blank. A customer shares a "public" doc and the preview shows the title of a different doc, because the template never got per-document tags.

None of these are bugs in Slack or WhatsApp. They're the result of a simple fact most app teams never design for: the server that builds the preview is not logged in. It requests your URL like a stranger would, with no cookies and no session, and draws a card from whatever HTML it gets back.

Marketing pages get a lot of attention for their link previews. App routes almost never do, even though they get pasted into chats all day. This post is a practical way to decide what each kind of private URL should show to a crawler, and a couple of mistakes that turn a cosmetic problem into a security one.

What actually happens when a private URL is pasted

Roughly, the unfurling service does this:

  1. GET the URL with its own user agent and no cookies.
  2. Follow redirects (most services follow a few).
  3. Parse the <head> of the final HTML response for og:* and twitter:* tags, falling back to <title> and the meta description.
  4. Fetch the og:image URL, again anonymously.
  5. Cache the result for a while.

You can reproduce the important part with curl. Leave your cookies out on purpose:

curl -sIL -A "facebookexternalhit/1.1" \
  https://app.example.com/projects/482/settings
Enter fullscreen mode Exit fullscreen mode

A typical app answers with a 302 to /login?next=..., so the card is built from your login page. Some apps answer 401 with a JSON body, so there are no tags at all and the card is empty. Single-page apps often answer 200 with an app shell that only fills in the title after JavaScript runs, which the crawler never executes.

So the first decision is not "which tags", it's "which response does an anonymous request get, on each kind of route?"

Sort your routes into three buckets

In practice, every app URL falls into one of these:

Bucket Examples What the card should say
Public Pricing, docs, public profiles, published pages Full, specific tags for that page
Shareable by link Invite links, "anyone with the link" docs, shared reports Something useful, but only what the link holder is allowed to know
Strictly private Dashboards, settings, billing, admin Generic product-level tags, nothing about the data

The mistakes happen when a route sits in one bucket but behaves like another. Let's take them in order.

Strictly private routes: make the generic card good

For dashboards and settings pages, the right card is boring on purpose: product name, a neutral line, a branded image. It should never contain a project name, customer name or anything else from the database.

Because unauthenticated requests usually redirect to the login page, your login page's tags are the preview for every private URL in your app. Treat them like a real page:

<!-- /login -->
<title>Sign in · Example</title>
<meta property="og:title" content="Example: project tracking for small agencies" />
<meta property="og:description" content="Sign in to open this page in Example." />
<meta property="og:image" content="https://www.example.com/og/app-default.png" />
<meta property="og:type" content="website" />
<meta name="robots" content="noindex" />
Enter fullscreen mode Exit fullscreen mode

"Sign in to open this page" is honest and tells the person in the chat what will happen when they click. That's a far better card than an empty box or your homepage hero.

If you'd rather not redirect, a layout-level default works too. In the Next.js App Router, a route group for the authenticated area can set metadata that never touches user data:

// app/(app)/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Example",
  description: "Sign in to open this page in Example.",
  robots: { index: false, follow: false },
  openGraph: {
    title: "Example: project tracking for small agencies",
    description: "Sign in to open this page in Example.",
    images: ["https://www.example.com/og/app-default.png"],
  },
};
Enter fullscreen mode Exit fullscreen mode

The key point: don't put generateMetadata that reads private records on routes a stranger can request. If it runs before your auth check, the title of project 482 ends up in the HTML a crawler receives, even if the page body is a redirect or an error.

Shareable-by-link routes: decide what the link itself grants

Invite links and "anyone with the link" documents are different. Holding the URL is the permission. So the card can be more useful, but you should consciously decide what goes in it, because the preview is also visible to everyone in the chat, and the unfurling service stores a copy.

A reasonable rule: the card may show what the person sharing the link would happily say out loud in that chat.

For an invite link, that's usually the workspace name and who invited you, if the inviter opted into it:

// app/invite/[token]/page.tsx
import type { Metadata } from "next";
import { getInvitePreview } from "@/lib/invites";

export async function generateMetadata(
  { params }: { params: Promise<{ token: string }> }
): Promise<Metadata> {
  const { token } = await params;
  const invite = await getInvitePreview(token); // read-only, no side effects

  if (!invite || invite.expired) {
    return { title: "Invite link expired · Example", robots: { index: false } };
  }

  return {
    title: `Join ${invite.workspaceName} on Example`,
    robots: { index: false },
    openGraph: {
      title: `Join ${invite.workspaceName} on Example`,
      description: "You've been invited to collaborate. Open the link to accept.",
      images: ["https://www.example.com/og/invite.png"],
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

For shared docs, branch on the document's visibility, not on who is asking:

if (doc.visibility === "public-link") {
  // title and a short excerpt are fine: the owner chose to share them
} else {
  // fall back to the generic product card
}
Enter fullscreen mode Exit fullscreen mode

The token trap: a GET must not consume anything

This one bites real products. Link scanners, chat unfurlers and email security tools may request a URL before the human ever clicks it. If your invite, magic-login or password-reset link is single-use and the first GET consumes it, the bot uses it up and your user sees "this link has expired".

Rules that avoid it:

  • Viewing an invite or magic link page is read-only. It shows a button.
  • Accepting happens on an explicit action: a POST from that button, or a form submit.
  • Generating preview metadata uses a read-only lookup, as in getInvitePreview above. It never marks the token as used.

This is good HTTP hygiene anyway, but link previews are where you'll notice it first.

Don't serve richer tags to "crawler" user agents

It's tempting to detect facebookexternalhit, Slackbot or WhatsApp in the user agent and serve them a richer, data-filled card while humans get the login page. Please don't.

A user-agent string is just a header. Anyone can send it:

curl -s -A "Slackbot-LinkExpanding 1.0" https://app.example.com/projects/482 | grep og:title
Enter fullscreen mode Exit fullscreen mode

If that returns the private project title, you've built an unauthenticated data leak with extra steps. Whatever an anonymous request gets is what you should be comfortable publishing. Make the decision on the route and the resource's sharing setting, never on who claims to be asking.

The image needs the same treatment

The og:image is fetched separately and anonymously. Two common failures:

  • The image URL points at an authenticated asset route (/api/files/123/thumbnail), so the fetch returns a login redirect or a 401 and the card has no picture.
  • The image is a signed URL that expires in 15 minutes. It works when the crawler first fetches it, then the card breaks later for anyone whose app refreshes the preview.

Use stable, public, generic images for private and shareable routes. If you generate per-invite images, serve them from a route that only renders what the link already grants.

Testing it without fooling yourself

Your own browser is logged in, so it's the worst possible test. Instead:

  • Run the curl commands above with no cookies, and look at the final status code and the <head>.
  • Open the link in a private window.
  • Paste the URL into a checker that fetches from a server with no session. I use the Open Graph preview on OG Scope for this: it requests the public HTML, follows redirects and shows the tags and estimated cards it found, so you see what an anonymous visitor gets rather than what your logged-in tab shows. It deliberately won't fetch private or unreachable hosts, so use curl for an internal-only staging box.

Then check one URL from each bucket: a public page, an invite link, a shared doc and a dashboard route.

Checklist

  • [ ] The login page has deliberate, generic preview tags and noindex.
  • [ ] No metadata function reads private data before the auth check.
  • [ ] Shareable links show only what the link holder may know.
  • [ ] Invite, magic-link and reset pages are read-only on GET.
  • [ ] No user-agent sniffing for richer tags.
  • [ ] og:image on app routes is public, stable and generic.
  • [ ] Tested anonymously, not from your logged-in browser.

Private routes will get pasted into chats whether you plan for it or not. A few minutes on these defaults means the card says something sensible, and nothing it shouldn't.


Abid Ali is a web developer in Pakistan who builds small, free tools, including OG Scope, an Open Graph and link preview checker.

Top comments (0)