First Class Modules

Web links

Maintains searchable, categorized, location-aware external links with descriptions, custom fields, relationships, activity, and favorites.

Open raw .md
Runtime key
module.web-links
Experience
Curated external resources
Security
Server-enforced

Use Web Links to curate external tools, reference sites, customer resources, vendor portals, or research alongside the records and teams that use them.

Note

The bundled renderer key is module.web-links. First-class means BuildWithHQ supplies a native, typed, secured runtime experience inside normal app provisioning and the signed-in user's existing permissions.

What it does

Maintains searchable, categorized, location-aware external links with descriptions, custom fields, relationships, activity, and favorites.

Key capabilities

  • Search links and filter them by category.
  • Create and edit title, URL, description, category, and custom fields.
  • Open only contract-validated HTTP or HTTPS destinations in an isolated browsing context.
  • Relate and favorite links while preserving location-aware access.

Common uses

  • Team resource directories.
  • Customer/vendor portals and external documentation.
  • Research links connected to cases, projects, accounts, or knowledge.

How it connects

Links can relate to Knowledge Articles, Contacts, projects, and dynamic records. They store the reference and business context, not credentials or an embedded authenticated browser session.

Where applicable, its records use the universal RecordId conventions so they can participate in secured relationships, activity history, favorites, dynamic fields, notifications, Inbox attention, and global search without copying the source record.

Security and data boundary

The server accepts strict host-based HTTP(S) URLs without embedded credentials. The runtime opens destinations with an isolated external context and applies normal tenant, DataRole, and location checks to the link record.

  • The authenticated service derives the SaaS app, tenant account, user, DataRole, and location scope; browser identifiers are never authorization proof.
  • The page editor composes React components with validated data bindings. Those bindings call typed runtime APIs, whose application services execute reviewed stored procedures.
  • List, search, detail, relation, activity, favorite, and write operations reapply their required server-side permissions.

Add it to an app

  1. Open the SaaS app in the Developer Console and identify the user journey and page where this module belongs.
  2. Add the validated module.web-links module block through the supported page/template authoring flow.
  3. Configure the module with the page editor's React components and validated data bindings; the bindings call authenticated platform APIs backed by reviewed stored procedures.
  4. Place the page in the correct user-type menus and assign existing DataRole, record, field, and location permissions.
  5. Test list, detail, search, empty, denied, stale-update, and cross-location behavior before publishing an exact version.

The native block includes secured search/category browsing, create/edit, detail, custom fields, relation/activity counts, isolated external opening, and favorites:

{
  "blocks": [
    { "_id": "resource-links", "_type": "module.web-links", "props": {}, "children": [] }
  ]
}

Custom unstyled links directory

import { FormEvent, useEffect, useState } from "react";
import type { WebLinkDetail, WebLinksList } from "@buildwithhq/module-sdk";
import { getWebLink, listWebLinks } from "./api";

const empty: WebLinksList = { contractVersion: 1, pageNumber: 1, pageSize: 50, totalRecords: 0, totalPages: 0, canCreate: false, canEdit: false, items: [] };

export function UnstyledWebLinks({ category = "", locationId = "" }) {
  const [draft, setDraft] = useState(""); const [search, setSearch] = useState(""); const [page, setPage] = useState(1);
  const [result, setResult] = useState<WebLinksList>(empty); const [selected, setSelected] = useState<WebLinkDetail | null>(null);
  useEffect(() => { const controller = new AbortController(); listWebLinks(search, category, controller.signal, locationId, page, 50).then(setResult); return () => controller.abort(); }, [category, locationId, page, search]);
  function submit(event: FormEvent) { event.preventDefault(); setPage(1); setSearch(draft.trim()); }
  async function open(recordId: string) { setSelected(await getWebLink(recordId)); }
  return <section className="custom-links"><h1>Resources</h1><form onSubmit={submit} role="search"><label>Search<input value={draft} onChange={e => setDraft(e.target.value)} /></label><button>Search</button></form>
    <ul>{result.items.map(link => <li key={link.recordId}><button onClick={() => void open(link.recordId)}><strong>{link.title}</strong><span>{link.category || "Uncategorized"}</span></button></li>)}</ul>
    <nav><button disabled={page <= 1} onClick={() => setPage(value => value - 1)}>Previous</button><span>Page {result.pageNumber} of {Math.max(1, result.totalPages)}</span><button disabled={page >= result.totalPages} onClick={() => setPage(value => value + 1)}>Next</button></nav>
    {selected && <article><h2>{selected.title}</h2><p>{selected.description}</p><a href={selected.url} target="_blank" rel="noopener noreferrer">Open external resource</a></article>}
  </section>;
}

Create, replace, and favorite links

Only absolute HTTP/HTTPS URLs with a real host and no embedded username/password are accepted. Use a managed connector for credentials. Updates are full optimistic replacements.

import type { WebLinkDetail, WebLinkDynamicField } from "@buildwithhq/module-sdk";
import { createWebLink, getWebLink, setWebLinkFavorite, updateWebLink } from "./api";

const fields = (current: WebLinkDetail) => current.dynamicFields.map((field: WebLinkDynamicField) => ({ fieldKey: field.fieldKey, valueText: field.valueText ?? undefined, valueInt: field.valueInt ?? undefined, valueDecimal: field.valueDecimal ?? undefined, valueDateTime: field.valueDateTime ?? undefined, valueBool: field.valueBool ?? undefined, valueGuid: field.valueGuid ?? undefined, valueJson: field.valueJson }));

export const addDocumentation = () => createWebLink({ title: "Product documentation", url: "https://docs.example.com/", category: "Reference", description: "Public product documentation" });
export async function renameLink(current: WebLinkDetail, title: string) { await updateWebLink(current.recordId, { expectedUpdatedUtc: current.updatedUtc, title, url: current.url, description: current.description || "", category: current.category || "", locationId: current.locationId || undefined, dynamicFields: fields(current) }); return getWebLink(current.recordId); }
export async function toggleLinkFavorite(current: WebLinkDetail) { await setWebLinkFavorite(current.recordId, !current.isFavorite); return getWebLink(current.recordId); }

Optional styling

.custom-links { width: 100%; max-width: none; }
.custom-links form, .custom-links nav { display: flex; gap: .75rem; }
.custom-links ul { list-style: none; margin: 1rem 0; padding: 0; }
.custom-links li button { background: transparent; border: 0; display: grid; padding: .75rem 0; text-align: left; width: 100%; }
.custom-links article { border: 1px solid var(--line, #d8dee8); margin-top: 1rem; padding: 1rem; }

Exact data path

GET /api/modules/web-links and GET /{recordId} use sp_WebLinks_ListSecured and sp_WebLinks_GetSecured. POST, PUT /{recordId}, and PUT /{recordId}/favorite use the reviewed create, update, and favorite procedures. Pages are capped at 200 rows; URL is capped at 1,000 characters, title at 300, and dynamic fields at 200.

Build a professional Web links dashboard

These six registry-backed presentation blocks let a designer turn the secured Web links API into a complete admin page without writing a chart, grid, status badge, empty state, or timeline from scratch. The normal operational API begins at /api/modules/web-links; a dashboard-wide count or trend should come from a separate purpose-built presentation binding so the browser never downloads a broad record population to calculate one number.

The ZIP contains this feature's fictional design composition at pages/first-class/web-links-sample.json, its executable authenticated page at pages/first-class/web-links-live.json, and the catalog-driven FirstClassPresentationGallery.tsx. Use the sample only for visual design. The live page calls the real native API and reviewed stored procedures under the signed-in user's scope.

Payload kindRuntime blockUseful Web links projection
metric-setcore.metric-stripActive, new, favorite, and review-due counts
entity-listcore.entity-listRecently added or maintained authorized links
progress-listcore.progress-listDistribution by category, owner, or state
series-chartcore.series-chartLink creation and maintenance over time
data-gridcore.presentation-gridOne bounded link page with safe display URLs
timelinecore.timelineCreate, edit, favorite, relate, review, and archive events

Copy/paste design preview: all six blocks

This complete static page document renders immediately in Puck/Monaco and is useful while styling a template. Its names, counts, dates, and IDs are fictional design fixtures; static preview values are not live tenant facts.

Copy the complete six-block page JSON
{
  "blocks": [
    {
      "_id": "web-links-metrics",
      "_type": "core.metric-strip",
      "props": {
        "title": "Web link library",
        "asOfUtc": "2026-09-04T18:00:00Z",
        "items": [
          {
            "key": "active",
            "label": "Active links",
            "value": 238,
            "format": "number",
            "tone": "success"
          },
          {
            "key": "new",
            "label": "Added this month",
            "value": 17,
            "format": "number",
            "tone": "primary"
          },
          {
            "key": "favorites",
            "label": "Your favorites",
            "value": 14,
            "format": "number",
            "tone": "warning"
          },
          {
            "key": "review",
            "label": "Review due",
            "value": 8,
            "format": "number",
            "tone": "danger"
          }
        ]
      },
      "children": []
    },
    {
      "_id": "web-links-recent",
      "_type": "core.entity-list",
      "props": {
        "title": "Recently updated links",
        "hasMore": true,
        "items": [
          {
            "id": "web-links-sample-1",
            "recordId": "web-links-record-1",
            "primary": "Field safety portal",
            "secondary": "Operations - docs.example.test",
            "status": {
              "key": "active",
              "label": "Active",
              "tone": "success"
            },
            "trailing": "Favorite"
          },
          {
            "id": "web-links-sample-2",
            "recordId": "web-links-record-2",
            "primary": "Field safety portal - Follow-up",
            "secondary": "Operations - docs.example.test - Updated two hours ago by the assigned owner",
            "status": {
              "key": "in-review",
              "label": "In review",
              "tone": "primary"
            },
            "trailing": "Today"
          },
          {
            "id": "web-links-sample-3",
            "recordId": "web-links-record-3",
            "primary": "Field safety portal - West region",
            "secondary": "Operations - docs.example.test - Related to three visible records at the Reno location",
            "status": {
              "key": "on-track",
              "label": "On track",
              "tone": "success"
            },
            "trailing": "3 related"
          },
          {
            "id": "web-links-sample-4",
            "recordId": "web-links-record-4",
            "primary": "Field safety portal - Customer response",
            "secondary": "Operations - docs.example.test - Waiting for an external response before work can continue",
            "status": {
              "key": "scheduled",
              "label": "Scheduled",
              "tone": "warning"
            },
            "trailing": "Tomorrow"
          },
          {
            "id": "web-links-sample-5",
            "recordId": "web-links-record-5",
            "primary": "Field safety portal - Regional operations review with a deliberately long title",
            "secondary": "Operations - docs.example.test - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
            "status": {
              "key": "needs-attention",
              "label": "Needs attention",
              "tone": "danger"
            },
            "trailing": "Review",
            "tertiary": "Long-content fixture: verify keyboard focus, wrapping, narrow columns, and mobile overflow before publishing."
          },
          {
            "id": "web-links-sample-6",
            "recordId": "web-links-record-6",
            "primary": "Field safety portal - Completed preview",
            "secondary": "Operations - docs.example.test - Closed after review with its related evidence retained",
            "status": {
              "key": "complete",
              "label": "Complete",
              "tone": "success"
            },
            "trailing": "Closed"
          }
        ]
      },
      "children": []
    },
    {
      "_id": "web-links-bystatus",
      "_type": "core.progress-list",
      "props": {
        "title": "Links by category",
        "items": [
          {
            "key": "operations",
            "label": "Operations",
            "value": 96,
            "maximum": 238,
            "displayValue": "96",
            "tone": "primary",
            "status": {
              "key": "operations",
              "label": "Operations",
              "tone": "primary"
            }
          },
          {
            "key": "sales",
            "label": "Sales",
            "value": 74,
            "maximum": 238,
            "displayValue": "74",
            "tone": "success",
            "status": {
              "key": "sales",
              "label": "Sales",
              "tone": "success"
            }
          },
          {
            "key": "support",
            "label": "Support",
            "value": 68,
            "maximum": 238,
            "displayValue": "68",
            "tone": "warning",
            "status": {
              "key": "support",
              "label": "Support",
              "tone": "warning"
            }
          }
        ]
      },
      "children": []
    },
    {
      "_id": "web-links-trend",
      "_type": "core.series-chart",
      "props": {
        "title": "Link maintenance",
        "variant": "bar",
        "defaultPeriodKey": "d7",
        "periods": [
          {
            "key": "d7",
            "label": "7 days",
            "labels": [
              "Fri",
              "Sat",
              "Sun",
              "Mon",
              "Tue",
              "Wed",
              "Thu"
            ],
            "series": [
              {
                "key": "primary",
                "label": "Added",
                "tone": "primary",
                "values": [
                  8,
                  5,
                  4,
                  12,
                  15,
                  11,
                  17
                ]
              },
              {
                "key": "secondary",
                "label": "Updated",
                "tone": "success",
                "values": [
                  6,
                  4,
                  3,
                  9,
                  12,
                  10,
                  14
                ]
              }
            ]
          }
        ]
      },
      "children": []
    },
    {
      "_id": "web-links-table",
      "_type": "core.presentation-grid",
      "props": {
        "title": "Authorized web links",
        "columns": [
          {
            "key": "name",
            "label": "Link",
            "type": "text",
            "align": "left"
          },
          {
            "key": "host",
            "label": "Host",
            "type": "text",
            "align": "left"
          },
          {
            "key": "category",
            "label": "Category",
            "type": "text",
            "align": "left"
          },
          {
            "key": "status",
            "label": "Status",
            "type": "status",
            "align": "left"
          },
          {
            "key": "updated",
            "label": "Updated",
            "type": "date",
            "align": "left"
          }
        ],
        "rows": [
          {
            "id": "web-links-row-1",
            "recordId": "web-links-record-1",
            "cells": {
              "name": "Field safety portal",
              "host": "docs.example.test",
              "category": "Operations",
              "status": {
                "key": "active",
                "label": "Active",
                "tone": "success"
              },
              "updated": "2026-09-04T13:20:00Z"
            }
          },
          {
            "id": "web-links-row-2",
            "recordId": "web-links-record-2",
            "cells": {
              "name": "Field safety portal - Follow-up",
              "host": "docs.example.test",
              "category": "Operations",
              "status": {
                "key": "in-review",
                "label": "In review",
                "tone": "primary"
              },
              "updated": "2026-09-04T15:42:00Z"
            }
          },
          {
            "id": "web-links-row-3",
            "recordId": "web-links-record-3",
            "cells": {
              "name": "Field safety portal - West region",
              "host": "docs.example.test",
              "category": "Operations",
              "status": {
                "key": "on-track",
                "label": "On track",
                "tone": "success"
              },
              "updated": "2026-09-04T12:18:00Z"
            }
          },
          {
            "id": "web-links-row-4",
            "recordId": "web-links-record-4",
            "cells": {
              "name": "Field safety portal - Customer response",
              "host": "docs.example.test",
              "category": "Operations",
              "status": {
                "key": "scheduled",
                "label": "Scheduled",
                "tone": "warning"
              },
              "updated": "2026-09-03T21:07:00Z"
            }
          },
          {
            "id": "web-links-row-5",
            "recordId": "web-links-record-5",
            "cells": {
              "name": "Field safety portal - Regional operations review with a deliberately long title",
              "host": "docs.example.test - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
              "category": "Operations",
              "status": {
                "key": "needs-attention",
                "label": "Needs attention",
                "tone": "danger"
              },
              "updated": "2026-09-03T16:31:00Z"
            }
          },
          {
            "id": "web-links-row-6",
            "recordId": "web-links-record-6",
            "cells": {
              "name": "Field safety portal - Completed preview",
              "host": "docs.example.test",
              "category": "Operations",
              "status": {
                "key": "complete",
                "label": "Complete",
                "tone": "success"
              },
              "updated": "2026-09-02T19:14:00Z"
            }
          }
        ],
        "page": {
          "pageNumber": 1,
          "pageSize": 6,
          "totalRecords": 238,
          "totalIsExact": true,
          "hasMore": true
        }
      },
      "children": []
    },
    {
      "_id": "web-links-timeline",
      "_type": "core.timeline",
      "props": {
        "title": "Link activity",
        "hasMore": true,
        "items": [
          {
            "id": "web-links-event-1",
            "recordId": "web-links-record-1",
            "occurredUtc": "2026-09-04T17:58:00Z",
            "title": "Web link updated",
            "description": "The Field safety portal description was refreshed.",
            "actor": "Jordan Lee",
            "tone": "neutral"
          },
          {
            "id": "web-links-event-2",
            "recordId": "web-links-record-2",
            "occurredUtc": "2026-09-04T15:42:00Z",
            "title": "Web link updated - Follow-up",
            "description": "The Field safety portal description was refreshed. Updated two hours ago by the assigned owner.",
            "actor": "Avery Patel",
            "tone": "primary"
          },
          {
            "id": "web-links-event-3",
            "recordId": "web-links-record-3",
            "occurredUtc": "2026-09-04T12:18:00Z",
            "title": "Web link updated - West region",
            "description": "The Field safety portal description was refreshed. Related to three visible records at the Reno location.",
            "actor": "Sam Rivera",
            "tone": "success"
          },
          {
            "id": "web-links-event-4",
            "recordId": "web-links-record-4",
            "occurredUtc": "2026-09-03T21:07:00Z",
            "title": "Web link updated - Customer response",
            "description": "The Field safety portal description was refreshed. Waiting for an external response before work can continue.",
            "actor": "Maya Chen",
            "tone": "warning"
          },
          {
            "id": "web-links-event-5",
            "recordId": "web-links-record-5",
            "occurredUtc": "2026-09-03T16:31:00Z",
            "title": "Web link updated - Regional operations review with a deliberately long title",
            "description": "The Field safety portal description was refreshed. This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
            "actor": "Automation",
            "tone": "danger"
          },
          {
            "id": "web-links-event-6",
            "recordId": "web-links-record-6",
            "occurredUtc": "2026-09-02T19:14:00Z",
            "title": "Web link updated - Completed preview",
            "description": "The Field safety portal description was refreshed. Closed after review with its related evidence retained.",
            "actor": "Jordan Lee",
            "tone": "success"
          }
        ]
      },
      "children": []
    }
  ]
}

Copy/paste React composition

Copy InteractivePresentationComponents.tsx and its optional CSS from the Professional Foundation Developer Kit, then add this module-specific composition. It is semantic and unstyled by default; pass styled after importing interactive-presentation-components.css for the supplied polished foundation. Either version accepts only a bounded already-authorized document and fails closed through the shared strict parsers.

import {
  EntityList,
  MetricStrip,
  PresentationGrid,
  ProgressList,
  SeriesChart,
  Timeline,
} from "./InteractivePresentationComponents";

export interface WebLinksPresentationData {
  readonly metrics: unknown;
  readonly recent: unknown;
  readonly byStatus: unknown;
  readonly trend: unknown;
  readonly table: unknown;
  readonly timeline: unknown;
}

export interface WebLinksPresentationProps {
  /** Pass only the already-authorized presentation document returned by the API. */
  readonly data?: WebLinksPresentationData | null;
  readonly loading?: boolean;
  readonly error?: boolean;
  readonly styled?: boolean;
  /** Record identity is navigation context; the detail API must authorize it again. */
  readonly onOpenRecord?: (recordId: string) => void;
}

export function WebLinksPresentation({
  data,
  loading = false,
  error = false,
  styled = false,
  onOpenRecord,
}: WebLinksPresentationProps) {
  if (error) return <p role="alert">The Web links presentation could not be loaded.</p>;
  if (loading || !data) return <p role="status">Loading Web links presentation...</p>;

  return (
    <main className={styled ? "bwhq-api-example" : undefined}>
      <header>
        <p>Web links</p>
        <h1>Web link library</h1>
        <p>Authorized links, categories, ownership, favorites, and recent maintenance.</p>
      </header>

      <MetricStrip data={data.metrics} styled={styled} />
      <div className={styled ? "bwhq-api-example__split" : undefined}>
        <ProgressList data={data.byStatus} styled={styled} />
        <EntityList data={data.recent} styled={styled} onOpenRecord={onOpenRecord} />
      </div>
      <SeriesChart data={data.trend} styled={styled} />
      <PresentationGrid data={data.table} styled={styled} onOpenRecord={onOpenRecord} />
      <Timeline data={data.timeline} styled={styled} onOpenRecord={onOpenRecord} />
    </main>
  );
}

Copy the live, authenticated module page

This document has no placeholder key and needs no invented endpoint. Save it to a page and add that page to a User Type menu. The registered native component calls /api/modules/web-links, uses the current tenant session, and preserves the module's real list, detail, create/update, pagination, empty, loading, and error behavior. Dynamic Records discovers the organization's real tenant-owned modules when no module key is configured.

Copy the executable live page
{
  "blocks": [
    {
      "_id": "web-links-live",
      "_type": "module.web-links",
      "props": {},
      "children": []
    }
  ]
}

The server derives SaaS app, organization, user, DataRoles, locations, module-specific membership/privacy, and any AI-read gate from verified identity. A returned identifier can select a detail target, but the detail or write endpoint authorizes it again. Page layout, status, tone, totals, action names, and identifiers never grant authority.

Tip

Store the public destination only. If an integration needs OAuth or an API secret, use a managed connector instead of putting credentials in a URL.

Important

A renderer being bundled in the tenant application does not make its data visible in every app. The server returns only components and records authorized for the current app and signed-in user; unavailable or unauthorized blocks fail closed.

Capability review: 2026-09-14. For exact current technical availability, use the generated API Map and first-class module inventory.