On this page
Build a Complete Member Dashboard
This example builds a member dashboard with a static frontend and a Site Members Function. Every member receives safe startup information; only Owners and Admins can request the private report records.
Create the resources
- Publish a Site named
my-siteor another available name. Choose Invite-only or Private Site Access and invite a Viewer for testing. - Create a Table in the API tab named Reports with a Text field
title. Add a sample record such as “September overview”. - Configure the Table as Developer API and create a server key with read permission. Keep the Table’s reads private to that credential.
- Create a Site Members Function linked to the Site. Use
my-app-apior another available slug; update the frontend URL if you choose a different slug. - Add Function Secrets
TABLE_API_URL(the Table’s copied base URL) andTABLE_API_KEY(the private server key). - Paste the Function code below, enable GET, Test
/bootstrap, and Publish.
Function: authorize before reading private data
export default {
async fetch(request, env) {
const user = env.MINIUP_USER;
if (!user) return Response.json({ error: "Sign in required" }, { status: 401 });
const url = new URL(request.url);
if (request.method !== "GET") {
return Response.json({ error: "Method not allowed" }, { status: 405 });
}
const canReadReports = user.role === "owner" || user.role === "admin";
if (url.pathname.endsWith("/bootstrap")) {
return Response.json({
user: { name: user.name || "Member", role: user.role },
features: { dashboard: true, reports: canReadReports }
});
}
if (url.pathname.endsWith("/reports")) {
if (!canReadReports) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
if (!env.TABLE_API_URL || !env.TABLE_API_KEY) {
return Response.json({ error: "Reports not configured" }, { status: 503 });
}
try {
const source = new URL(env.TABLE_API_URL);
source.searchParams.set("limit", "25");
const response = await fetch(source, {
headers: { "x-miniup-api-key": env.TABLE_API_KEY }
});
if (!response.ok) throw new Error("Report source failed");
const data = await response.json();
if (!Array.isArray(data.records)) throw new Error("Invalid report data");
return Response.json({
reports: data.records.map(record => ({
id: record.id, title: String(record.fields.title || "Untitled report")
}))
});
} catch {
return Response.json({ error: "Reports unavailable" }, { status: 502 });
}
}
return Response.json({ error: "Not found" }, { status: 404 });
}
};The report route checks the trusted role on every request and returns only id and title. It does not pass the member’s browser-supplied URL or role to the private data source.
Frontend: index.html
Upload these three frontend files to the Site. The main page uses a responsive layout, accessible status, a retry action, and a component loaded after bootstrap.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Member dashboard</title>
<style>
body { font: 17px/1.6 system-ui; margin: auto; padding: 24px; max-width: 56rem; color: #14283e; }
button { font: inherit; min-height: 44px; padding: 8px 16px; cursor: pointer; }
button:focus-visible { outline: 3px solid #2875bc; outline-offset: 3px; }
#dashboard { padding: 20px; border: 1px solid #ccd9e5; border-radius: 12px; }
li { overflow-wrap: anywhere; }
</style>
</head>
<body>
<main>
<h1>Member dashboard</h1>
<p id="status" role="status" aria-live="polite">Loading your app…</p>
<button id="retry" type="button" hidden>Retry</button>
<section id="dashboard" aria-label="Your dashboard" hidden></section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>Frontend: app.js
const status = document.querySelector("#status");
const retry = document.querySelector("#retry");
const dashboard = document.querySelector("#dashboard");
const base = "/api/functions/my-app-api";
async function load() {
retry.hidden = true;
dashboard.hidden = true;
status.textContent = "Loading your app…";
try {
const response = await fetch(`${base}/bootstrap`);
if (!response.ok) throw new Error(`Unable to load your app (${response.status}). Check Site membership.`);
const app = await response.json();
if (!app.features.dashboard) throw new Error("Dashboard unavailable.");
const component = await import("./components/dashboard.js");
await component.render(app, base, dashboard);
dashboard.hidden = false;
status.textContent = "";
} catch (error) {
status.textContent = error.message;
retry.hidden = false;
}
}
retry.addEventListener("click", load);
load();Frontend: components/dashboard.js
export async function render(app, base, container) {
container.replaceChildren();
const greeting = document.createElement("h2");
greeting.textContent = `Welcome, ${app.user.name}`;
container.append(greeting);
if (!app.features.reports) {
const note = document.createElement("p");
note.textContent = "Your membership does not include report access.";
container.append(note);
return;
}
const response = await fetch(`${base}/reports`);
if (!response.ok) throw new Error(`Reports could not load (${response.status}).`);
const { reports } = await response.json();
if (!reports.length) {
const empty = document.createElement("p");
empty.textContent = "No reports yet.";
container.append(empty);
return;
}
const list = document.createElement("ul");
for (const report of reports) {
const item = document.createElement("li");
item.textContent = report.title;
list.append(item);
}
container.append(list);
}Validate the complete app
- As Owner, open the published Site. Expect the greeting and up to 25 real report records.
- With an empty Reports Table, expect “No reports yet.”
- As Viewer, expect the greeting and the no-report-access message.
- As Viewer, call
/api/functions/my-app-api/reportsdirectly. Expect 403 even if someone modifies frontend flags. - While signed out, verify protected Site access and denied Function access.
- Temporarily use invalid test credentials in the Function preview to check the safe 502 error. Restore valid Secrets and publish before sharing.
This example intentionally provides a bounded read-only report list. Add pagination and record-specific rules before extending it to a larger business workflow. Do not assume a role check alone gives per-record isolation.