Back to Explore
GuideVibeFix 编辑部Updated Oct 16, 2026

Stop Running Your Share Links Naked: Notion-Style Share-Link Engineering for Vibe Projects

Copy-link sharing is SaaS’s cheapest growth loop — and the feature AI code most often ships naked. Three failures dissected (enumerable IDs, no expiry, dirty revocation), then the full fix: unguessable shr_ tokens, three-tier view/comment/edit permissions on three Postgres tables, a runnable expiry + usage-cap middleware, revoke/rotation with cache invalidation, anti-enumeration defenses, and consumption-side og tags. Closes with a solo-team weekend plan and a 10-item launch checklist.

Notion-style share dialog screenshot: Share panel with permission tiers and a Copy link button

This article is about share links you send to humans to click, with a clear division of labor against three sibling pieces on this site: vibe-project-customer-api-keys-guide (developer keys) covers "long-lived credentials for machines to call" — this one covers "temporary links for humans to click." Different issuance targets. Cross-link: https://vibefix.work/zh/explore/vibe-project-customer-api-keys-guide. vibe-project-auth-login-permissions-guide (the 5-piece login & permissions playbook) covers "how to build the permission model" — this one covers "how permissions land in the sharing scenario." Model vs. landing. Cross-link: https://vibefix.work/zh/explore/vibe-project-auth-login-permissions-guide. vibe-project-og-image-guide (dynamic OG image generation, shipping in the same cycle) covers "how share preview images get produced" — Section 7 of this article covers "which og tags a share link needs on the consumption side." Production vs. consumption. Cross-link: https://vibefix.work/zh/explore/vibe-project-og-image-guide.

Nearly every SaaS ships "copy link sharing": Notion's Share → Copy link, Google Docs' "anyone with the link", Figma's prototype links. For a vibe-coded project, this is more than a feature — it's the cheapest growth loop you have. A user dropping your product link into a group chat is free distribution. But AI-generated share-link implementations are usually running naked: enumerable auto-increment IDs, links that never expire, "revoke" buttons that leave the page sitting in the other person's browser cache. This guide takes the share link apart from issuance to revocation and hands you a minimal implementation a solo team can copy today.

1. The three classic mistakes AI loves to make

Anti-patterns first. The three snippets below are all patterns seen in real projects (lightly anonymized), each followed by its severity rating.

Mistake 1: Enumerable IDs

// Anti-pattern: share links that look like this
// https://yourapp.com/s/10482
// https://yourapp.com/share?id=10483

app.get('/s/:id', async (req, res) => {
  const doc = await db.docs.findByPk(req.params.id); // auto-increment PK exposed directly
  res.json(doc);
});

An auto-increment ID is your database's internal ledger — not a street address for strangers. The moment a link reads /s/10482, an attacker loops from 1 to 20000 and scrapes every "shared" document on the site. That's an enumeration attack, and it costs a few lines of script plus a cup of coffee. Severity: high. It doesn't just leak shared content — it leaks business scale (the max ID is your user/document count) and exposes unshared resources too, because most such implementations never check "is this ID even allowed to be shared" before returning it.

Mistake 2: No expiration

// Anti-pattern: no expires_at column on the token table
// once issued, the link works forever

app.get('/s/:token', async (req, res) => {
  const share = await db.shares.findOne({ where: { token: req.params.token } });
  if (!share) return res.sendStatus(404);
  res.json(share.doc); // zero time validation
});

A share link is, at its core, a temporary grant. In Notion-style products, 90% of sharing is "let someone take a look": reviewing a design mock, aligning on a spec, showing a portfolio piece in an interview. A never-expiring link turns a temporary grant into a permanent backdoor — the ex-employee's link still works, the ex-client's link still works, the link dropped in a group chat three years ago still works. Severity: medium-high. A single link's blast radius grows linearly with time: the longer it lives, the likelier it gets forwarded, crawled, and indexed.

Mistake 3: Dirty revocation

// Anti-pattern: revoke = delete the DB row, done
app.post('/shares/:id/revoke', async (req, res) => {
  await db.shares.destroy({ where: { id: req.params.id } });
  res.json({ ok: true }); // cache? CDN? the other browser? not our problem
});

A "revoked" link that still opens is the most trust-destroying bug a sharing feature can have. Dirty revocation usually misses three places: first, a CDN or reverse proxy cached the share page's HTML — the origin deleted the data but the edge keeps serving the stale page until TTL; second, the tab already open in the recipient's browser — the content is loaded into memory and revocation can't reach client-side memory; third, search engine snapshots and chat-app unfurl caches — third-party caches you can never delete. Severity: medium. The first two are engineering problems you can fix; the third is physics, mitigated only by "short cache TTLs + don't put sensitive content on anonymous links." The design goal for revocation: after revoke, no new request opens the link; any already-open page dies on refresh.

All three mistakes share one root cause: treating the share link as an ordinary page route instead of as a credential. Credentials need to be unguessable, time-bounded, and revocable — a share link needs every one of those properties. The rest of this guide adds them back, one by one.

2. Token design: unguessable, recognizable, sensibly sized

A share token only needs three properties: others can't guess it, you can recognize it, and it isn't absurdly long.

Unguessable: use a cryptographically secure RNG

In Node, the standard answer is crypto.randomBytes; in browsers, the Web Crypto API. The nanoid README says it explicitly: under the hood it uses Node's crypto module and the browser Web Crypto API — a hardware random number generator — and it explicitly warns against Math.random(), which is predictable and unfit for credentials.

import crypto from 'node:crypto';

// 32 bytes = 256 bits of entropy, 43 chars in base64url
// shr_ prefix: instantly recognizable in logs as a share token,
// not an API key or a session
export function issueShareToken() {
  return 'shr_' + crypto.randomBytes(32).toString('base64url');
}
// => "shr_X9fK2mQ8vN7bL4pR6tW1yU3iO5aS0dF8gH2jK4lM6n"

// Store only the hash; the raw token is returned to its creator once
export function hashToken(token) {
  return crypto.createHash('sha256').update(token).digest('hex');
}

Hash-don't-store-plaintext is credential management 101: a leaked DB backup or a SQL injection dump yields no usable tokens. Verification hashes the presented token the same way before comparing — one SHA-256, negligible cost.

Sizing: follow the official docs, don't guess

The nanoid README's "Comparison with UUID" section gives an explicit, citable figure: a default Nano ID packs 21 characters — about 126 bits of random entropy — with collision probability comparable to UUID v4 (122 bits): "for there to be a one in a billion chance of duplication, 103 trillion version 4 IDs must be generated." The README also warns: if you shrink the size (e.g. nanoid(10)), run your alphabet and size through the official collision-probability calculator first.

Applied to share links, the decision is simple:

  • Default to 32 bytes (256 bits). Share-link volumes never approach "trillions" — 256 bits is "obviously enough," and 43 base64url characters still paste fine into a chat box.
  • For shorter IDs, nanoid's default 21 characters (126 bits) is fine. Per the official figure, a one-in-a-billion collision chance requires 103 trillion generations — your share links will never get there. Note this is the collision figure; enumeration resistance comes from "can't be guessed," and a 126-bit search space is equally astronomical.
  • Don't shrink to 8–10 characters for looks. With a 64-symbol alphabet, 10 characters carry only 60 bits of entropy. Per the README's warning, that length demands calculator verification — not worth saving a few characters on something where "guessed" means "unauthorized access."

Recognizable: the shr_ prefix

The prefix buys zero security. Its value is operational: shr_ in a log line is instantly a share token, sk_ an API key — fast triage during incidents and access audits. The Stripe-style prefix convention (sk_live_, whsec_) has proven the mental overhead is near zero.

3. Permission granularity: three tiers — view / comment / edit

Notion's share panel is the right reference: Can view, Can comment, Can edit, Full access. For a vibe project, the first three cover 95% of cases — "full access" (changing share settings, deleting the doc) belongs to the document owner and should never appear in a share link's permission menu.

Nail down the semantics of the three tiers, or frontend and backend will drift apart:

  • viewer (read-only): reads the body and comments, writes nothing. "Read-only" also means the read API strips privacy-sensitive fields like collaborators' email addresses by role.
  • commenter: viewer plus posting and replying to comments. Can't edit the body, can't delete others' comments (can delete their own).
  • editor: commenter plus editing the body. Can't change share settings, can't delete the document, can't re-share it to a fourth party (no transitive grants — that's how permission sprawl starts).

In the data model, that's three tables. shares holds the share record itself, share_grants holds "directed shares" (named recipients, each with their own tier), revocations holds revocation events. An anonymous link ("anyone with the link") is simply a shares row with zero share_grants — whoever holds it gets in, at the shares.role default tier.

ER diagram of the shares / share_grants / revocations tablesThree-table design: an anonymous link is a shares row with no grants; a directed share gives each recipient their own grant that can override the default tier; revocation is a standalone event table
-- PostgreSQL, runs as-is
CREATE TABLE shares (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  token_hash  TEXT NOT NULL UNIQUE,          -- sha256(token), unique index
  resource_id UUID NOT NULL,                -- the shared doc/project id
  created_by  UUID NOT NULL,                -- who created the share
  role        TEXT NOT NULL DEFAULT 'viewer'
              CHECK (role IN ('viewer', 'commenter', 'editor')),
  expires_at  TIMESTAMPTZ,                  -- NULL = never expires (discouraged, see §4)
  max_uses    INT,                           -- NULL = unlimited
  uses        INT NOT NULL DEFAULT 0,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_shares_resource ON shares(resource_id);

CREATE TABLE share_grants (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  share_id   UUID NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
  grantee_id UUID NOT NULL,                 -- the directly-authorized user
  role       TEXT NOT NULL CHECK (role IN ('viewer', 'commenter', 'editor')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (share_id, grantee_id)
);

-- Revocation is an event, not a state: append-only, so "who revoked
-- which link, when, and why" stays auditable
CREATE TABLE revocations (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  share_id   UUID NOT NULL UNIQUE REFERENCES shares(id) ON DELETE CASCADE,
  reason     TEXT,
  revoked_by UUID NOT NULL,
  revoked_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Why does revocation get its own table instead of a status column on shares? Two reasons: first, auditability — "who revoked what, when, and why" is a security event, and an append-only event table is more trustworthy than an overwritable status field. Second, semantic cleanliness — expiry, exhaustion, and revocation are three different ways to die (see the §5 state machine); stuffing them into one status enum means six months from now you won't remember how expired differed from revoked.

4. Expiry and usage caps: one unified validation middleware

expires_at and max_uses are two sides of one coin: one bounds "how long it lives," the other "how many times it opens." A portfolio link for interviews suits max_uses=5; a spec review suits expires_at = +7 days. Both columns may be NULL, but the product default should always fill in at least one — the "create share" form should offer expiry presets (7 days / 30 days / custom), not default to "never expires."

All validation logic must converge in a single middleware — not hand-written per route. The Express middleware below is runnable as-is (only express and pg required):

// middleware/shareGuard.js
import crypto from 'node:crypto';
import { pool } from '../db.js';

// ---- Rate limiting: 60 req/min per IP (methodological suggestion, tune to your product) ----
const WINDOW_MS = 60_000;
const MAX_REQ = 60;
const hits = new Map(); // in-memory, single instance; use Redis for multi-instance deploys
setInterval(() => {
  const now = Date.now();
  for (const [ip, arr] of hits) {
    const fresh = arr.filter(t => now - t < WINDOW_MS);
    fresh.length ? hits.set(ip, fresh) : hits.delete(ip);
  }
}, WINDOW_MS).unref();

export function shareRateLimit(req, res, next) {
  const ip = req.ip;
  const now = Date.now();
  const arr = (hits.get(ip) || []).filter(t => now - t < WINDOW_MS);
  arr.push(now);
  hits.set(ip, arr);
  if (arr.length > MAX_REQ) {
    res.set('Retry-After', '60');
    return res.status(429).json({ error: 'too_many_requests' });
  }
  next();
}

// ---- Unified share-token validation ----
const rank = { viewer: 1, commenter: 2, editor: 3 };

export function shareGuard(requiredRole = 'viewer') {
  return async (req, res, next) => {
    const tokenHash = crypto.createHash('sha256')
      .update(req.params.token).digest('hex');

    const { rows } = await pool.query(
      `SELECT s.*, r.revoked_at
         FROM shares s
         LEFT JOIN revocations r ON r.share_id = s.id
        WHERE s.token_hash = $1`,
      [tokenHash]
    );
    const share = rows[0];

    // Four ways to die converge on one identical 404:
    // nonexistent, revoked, expired, exhausted
    const dead = !share
      || share.revoked_at
      || (share.expires_at && new Date(share.expires_at) < new Date())
      || (share.max_uses != null && share.uses >= share.max_uses);
    if (dead) return res.status(404).json({ error: 'not_found' });

    if (rank[share.role] < rank[requiredRole]) {
      return res.status(403).json({ error: 'forbidden' });
    }

    // Directed share: when grants exist, only a logged-in grantee passes
    const g = await pool.query(
      `SELECT 1 FROM share_grants WHERE share_id = $1 LIMIT 1`, [share.id]);
    if (g.rowCount > 0) {
      if (!req.user) return res.status(401).json({ error: 'login_required' });
      const mine = await pool.query(
        `SELECT role FROM share_grants WHERE share_id = $1 AND grantee_id = $2`,
        [share.id, req.user.id]);
      if (!mine.rows[0]) return res.status(403).json({ error: 'forbidden' });
      if (rank[mine.rows[0].role] < rank[requiredRole]) {
        return res.status(403).json({ error: 'forbidden' });
      }
      req.shareRole = mine.rows[0].role; // directed tier overrides the default
    } else {
      req.shareRole = share.role;
    }

    // Atomic increment: under concurrency, two requests both reading
    // uses=4 against max_uses=5 — only one gets the 5th visit
    await pool.query(
      `UPDATE shares SET uses = uses + 1
        WHERE id = $1 AND (max_uses IS NULL OR uses < max_uses)`,
      [share.id]);

    req.share = share;
    next();
  };
}

// ---- Mounting ----
// app.get('/s/:token', shareRateLimit, shareGuard('viewer'), renderDoc);
// app.post('/s/:token/comments', shareRateLimit, shareGuard('commenter'), addComment);
// app.patch('/s/:token/doc', shareRateLimit, shareGuard('editor'), editDoc);

Three easy-to-miss traps are already handled here: first, the uses + 1 uses a conditional atomic UPDATE so "5 uses max" can't become 8 under concurrency; second, expiry is checked against the DB's expires_at and server time — never any client-supplied timestamp; third, and most important, every "can't open" case returns the same 404 — nonexistent token, revoked, expired, or exhausted all produce an identical response. Attackers can't distinguish "this token once existed" from "never existed" via 404-vs-410-vs-403 differences. That's the "no existence leakage" principle §6 expands on.

5. Revocation and rotation: making "take the link back" actually work

A share token has exactly four ways to die: natural expiry, usage exhaustion, revocation, and rotation-replacement. The full state machine is below — note that "rotation" isn't a fifth kind of death but an atomic "old token dies instantly, new token takes over" operation.

Share token lifecycle state machine: issue → active → expired / exhausted / revoked → rotateToken lifecycle: the three exits from active (expired / exhausted / revoked) can all go through rotation to mint a new token, killing the old link immediately

Revoke semantics: write an event, don't delete the row

// POST /api/shares/:id/revoke   (login required, must be the share creator)
app.post('/api/shares/:id/revoke', requireLogin, async (req, res) => {
  const { rows } = await pool.query(
    `SELECT id FROM shares WHERE id = $1 AND created_by = $2`,
    [req.params.id, req.user.id]);
  if (!rows[0]) return res.sendStatus(404);

  await pool.query(
    `INSERT INTO revocations (share_id, reason, revoked_by)
     VALUES ($1, $2, $3) ON CONFLICT (share_id) DO NOTHING`,
    [req.params.id, req.body.reason || null, req.user.id]);

  // The critical part: invalidate every cache layer,
  // or "revoked but still opens"
  await invalidateShareCache(req.params.id);
  res.json({ ok: true });
});

A revocation that only deletes the DB row is a fake revocation. Real revocation = write the revocations event + invalidate all cache layers, both in the same request. The cache-invalidation checklist, layer by layer:

  • App-layer cache (Redis/memory): keyed by a share:{shareId} prefix — bulk-delete by prefix on revoke. If your share-page render caches full HTML, that key must die here too.
  • CDN / reverse proxy: if share-page URLs (/s/shr_xxx) sit behind CDN caching, purge by URL on revoke. The lazier, safer default: don't CDN-cache share pages at all (Cache-Control: private, no-store). Share pages are personalized, permission-checked dynamic content — caching them saves little and plants landmines.
  • The recipient's already-open browser tab: the one place you can't reach. Mitigation: poll or WebSocket from the share page — every 60 seconds, re-validate the token against /api/shares/validate; on 404, replace the page in place with "this link no longer works." The user sees a dead state on refresh or tab-switch, not still-readable stale content.
  • Search-engine snapshots and chat-app unfurl caches: undeletable, full stop. Which is why §6 demands noindex on anonymous share pages by default — reduce the odds of being indexed at the source.

Rotation: same grant, new token

Rotation has concrete triggers: the link got forwarded to the wrong group, a partner's team changed, you suspect a leak but don't want to interrupt legitimate collaboration. Rotation = mint a new token inheriting the original share's resource_id, role, and grants; the old token goes straight into revocations. Wrap it in a transaction so there's no window between old and new:

// POST /api/shares/:id/rotate
await pool.query('BEGIN');
try {
  const newToken = issueShareToken();
  const { rows } = await pool.query(
    `INSERT INTO shares (token_hash, resource_id, created_by, role, expires_at, max_uses)
     SELECT $1, resource_id, created_by, role, expires_at, max_uses
       FROM shares WHERE id = $2 RETURNING id`,
    [hashToken(newToken), req.params.id]);
  await pool.query(
    `INSERT INTO share_grants (share_id, grantee_id, role)
     SELECT $1, grantee_id, role FROM share_grants WHERE share_id = $2`,
    [rows[0].id, req.params.id]);
  await pool.query(
    `INSERT INTO revocations (share_id, reason, revoked_by)
     VALUES ($1, 'rotated', $2)`,
    [req.params.id, req.user.id]);
  await pool.query('COMMIT');
  await invalidateShareCache(req.params.id);
  res.json({ token: newToken }); // the new token is returned exactly once
} catch (e) { await pool.query('ROLLBACK'); throw e; }

Note that uses restarts at 0 after rotation — a new link is a new lifecycle; don't carry the old link's consumed count forward.

6. Anti-enumeration and anti-scraping: unguessable and uncrawlable

An unguessable token is only layer one. Full enumeration defense is three layers:

Layer 1: rate limiting. §4's shareRateLimit is the runnable version: 60 requests/minute per IP, 429 beyond that. The threshold is a methodological suggestion — a legitimate user opening a share link won't hit 60 requests in a minute; an enumeration script burns through thousands. Tune to your product: stricter for B2B review links (20/min), looser for public consumer portfolios. The key rule: the limiter runs before token validation, so attackers get throttled before extracting any exist/not-exist signal.

Layer 2: uniform 404, no existence leakage. In the §4 middleware, every failure mode returns {"error":"not_found"} + 404. Three corollaries: first, a malformed token (wrong length) also returns 404, not 400 — a 400 would tell the attacker "you passed format validation." Second, keep response times consistent — if "token not found" and "token existed but expired" differ by 50ms, timing attacks can tell them apart. The fix: on a token miss, still run an equally-expensive hash plus a dummy query (or a fixed small sleep) to flatten the difference. For a solo team, "uniform status code + uniform body" already blocks 99% of probing; timing attacks are a next-stage concern. Third, never build the "helpful" design of 404-for-missing vs 403-for-no-permission — that's an existence-probing API with official documentation.

Layer 3: robots and noindex, so crawlers can't find them. In the share page's <head>:

<!-- Anonymous share pages: not indexed by default -->
<meta name="robots" content="noindex, nofollow" />

And in robots.txt:

User-agent: *
Disallow: /s/

Honesty note: noindex and robots.txt are gentlemen's agreements — malicious crawlers ignore them. They defend against the most common real-world leak path: someone casually forwards a link into a public group and a search engine indexes it. Deliberate malicious enumeration is what layers 1 and 2 are for.

7. Social unfurl: the consumption side of share links

When a link lands in WeChat, Slack, Discord, or X, nobody sees your page — they see a link card: title, description, an image. The card is generated by the other platform's crawler fetching your URL. That process is called unfurling. As the consumed side, your share page needs the right og tags:

<!-- Share page head: minimal consumption-side og set -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://yourapp.com/s/shr_X9fK..." />
<meta property="og:title" content="Q3 Roadmap Review Draft" />
<meta property="og:description" content="For product team review, expires in 7 days" />
<meta property="og:image" content="https://yourapp.com/api/og/share/abc123.png" />
<meta name="twitter:card" content="summary_large_image" />

Three engineering details here, each a landmine:

First, crawlers must pass token validation but must not consume usage counts. Slackbot, Twitterbot, and Discordbot arrive carrying the real token — if your shareGuard increments uses for them too, three forwards eat a 5-use budget before any human clicks. The fix: recognize major crawler User-Agents (Slackbot, Twitterbot, Discordbot, facebookexternalhit, LinkedInBot) and route them through a separate validation branch — validate the token (crawler requests with invalid tokens still 404), but skip uses + 1. User-Agents are spoofable, so this branch only skips counting; it never relaxes permissions.

Second, og:image must not sit behind auth. Crawlers fetch og:image without the token — the direct URL must be publicly reachable. The right pattern is a separately signed URL for the preview image (the production-side story for dynamic OG images is our dynamic OG image generation guide; this section covers only the consumption-side tags).

Third, kill previews for sensitive shares. If the share is an unreleased business plan, the safest move is giving crawlers nothing substantive: a title-only minimal og set, no description, no image — or even noindex plus an empty description. How pretty the link card looks always ranks below "don't leak."

8. The solo-team minimum: a shippable version you can build this weekend

Compress the previous seven sections into a minimal loop a solo dev can finish in a weekend. In order:

Step 1: create the tables. Run the §3 SQL as-is (three tables, PostgreSQL). On SQLite, generate UUIDs in app code and store ISO timestamps as TEXT — the logic doesn't change.

Step 2: the issuance endpoint. Creating a share = mint token → store hash → return the raw token once:

// POST /api/shares  (login required)
app.post('/api/shares', requireLogin, async (req, res) => {
  const { resourceId, role = 'viewer', expiresInDays = 7, maxUses = null } = req.body;
  if (!['viewer', 'commenter', 'editor'].includes(role)) {
    return res.status(400).json({ error: 'bad_role' });
  }
  const token = issueShareToken();
  const { rows } = await pool.query(
    `INSERT INTO shares (token_hash, resource_id, created_by, role, expires_at, max_uses)
     VALUES ($1, $2, $3, $4, now() + ($5 || ' days')::interval, $6)
     RETURNING id, expires_at`,
    [hashToken(token), resourceId, req.user.id, role, String(expiresInDays), maxUses]);
  res.json({ token, shareUrl: `https://yourapp.com/s/${token}`, ...rows[0] });
});

Step 3: mount the §4 middleware. shareRateLimit + shareGuard('viewer'/'commenter'/'editor'), mounted at the minimum tier each endpoint needs. Order matters: rateLimit first, guard second.

Step 4: the revoke endpoint. Copy §5's revoke plus invalidateShareCache. V1 can implement invalidateShareCache as "delete Redis prefix keys + Cache-Control: private, no-store on share pages" — add CDN purging when you actually have a CDN.

Step 5: walk the launch checklist, item by item.

  1. ✓ Tokens minted with crypto.randomBytes(32), shr_ prefix, SHA-256 hashes only in the DB
  2. ✓ No auto-increment IDs anywhere in share URLs; internal primary keys never appear in public URLs
  3. ✓ The create-share form has a default expiry (7 days suggested); "never expires" requires an explicit, confirmed choice
  4. ✓ expires_at and max_uses checks converge in one middleware mounted on every share route
  5. ✓ Nonexistent / revoked / expired / exhausted all return the identical 404 + identical body
  6. ✓ Rate limiting runs before token validation, threshold tuned to the product (suggested starting point: 60/min per IP)
  7. ✓ Revoke = write revocations event + clear app cache + Cache-Control: private, no-store on share pages
  8. ✓ Read APIs strip response fields by role; viewers never see collaborator emails or other private fields
  9. ✓ Share pages carry <meta name="robots" content="noindex, nofollow">; robots.txt disallows /s/
  10. ✓ Crawler UAs take the validate-but-don't-count branch; og:image uses a publicly reachable signed URL

Related reading: Stop Handing Out Master Keys: customer API key issuance, done right (long-lived credentials for machines); Don't Let AI Run Your User System Naked: the 5-piece login & permissions playbook (how to build the permission model); Dynamic OG image generation (how share preview images get produced — this article's §7 is the consumption side).

One-line summary: a share link is a temporary credential for humans to click — minted with a cryptographic RNG, stored as a hash, double-bounded by expiry and usage caps, revoked with cache invalidation, and every failure wearing the same 404. Hit those five and your "copy link sharing" is finally dressed.

Views 0Comments 0

Comments (0)

ME
0/1000
Loading comments...
Browse projectsPublish your project

Related articles

Cover: broken share preview (mojibake, cropped title, gray box) vs. a proper dark dynamic OG card, side by side
Guide
Stop Shipping Naked Link Previews: Dynamic OG Image Generation for Vibe Projects

AI-built sites either ship no OG image or render Chinese as tofu blocks. This guide wires up the satori pipeline end to end: JSX template to SVG to PNG, CJK font subsetting from megabytes to kilobytes, per-slug edge caching, and a fallback image on failure — a weekend build.

DeploymentIndie DevelopmentFrontend Engineering
Dark admin panel data table screenshot: user list with checkbox selection, sortable columns, search and refresh controls
Guide
Stop Fetching Everything at Once: Data Tables for Vibe Projects, in Practice

AI-generated tables are written for demo-sized data: full fetches that freeze the page, fake pagination, frontend sorting that silently misorders. This guide covers the interaction engineering of one table — offset vs. cursor pagination, sort whitelists, URL-synced filters, column preferences in JSONB, cross-page bulk selection, virtual scrolling test methods, and a 12-item launch checklist.

Data WorkflowsPerformanceBackend Engineering
Collaborative editor illustration: multiple colored cursors with name labels (Sophia, Charlie) editing the same document
Guide
Two People Editing the Same Line: Realtime Collaborative Editing for Vibe Projects

Two people editing the same line is a consistency problem, not a realtime problem — naive WebSocket JSON broadcast dies at three users. Scoped against the site's realtime-features and offline-sync guides, then deep: OT vs CRDT with a decision table (conclusion first: solo teams pick Yjs), the Y.Doc/Awareness/Provider trio (30-line client, 40-line authenticated server), room-level ACL plus snapshot backstops, production pitfalls, and a 5-item launch checklist.

Developer WorkflowData WorkflowsTeam Collaboration