Permissions
Show Edit and other actions only to visitors who may use them with useContentPermissions, while the VitNode Content Engine authorizes every write on the server. Explains staff, moderator and member permissions.
A page should show Edit only to people who may edit. useContentPermissions asks the Content Engine what the current visitor may do with the records on screen, so the page never compares author ids or guesses who is a moderator. The server still checks every write itself.
This continues the article from Edit form. Authors can edit at /news/:id/edit, but nothing links there yet.
Ask the Content Engine
Create src/pages/article-permissions.ts. It wraps useContentPermissions with the typed fetcher call for your articles:
import { useContentPermissions } from "@vitnode/core/tanstack/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { manifest } from "@/manifest.gen";
/** What the visitor may do with each article on screen: `canEdit`, `canReport`. */
export const useArticlePermissions = (ids: readonly number[]) =>
useContentPermissions({
ids,
queryKey: [manifest.id, "articles"],
fetch: async request => {
const res = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/news",
path: "/permissions",
args: { query: { ids: request.join(",") } },
});
if (res.status !== 200) throw new Error("Loading permissions failed.");
return await res.json();
},
});The route, GET /api/newsroom/content/news/permissions, exists because the article has publicWrite.update. queryKey starts with the article list's prefix, so refreshing the list refreshes the answers too.
Show the Edit link
Edit src/pages/article-page.tsx. Add an ArticleActions component above ArticlePage, and render it in the article's header. These are excerpts:
import { buttonVariants } from "@vitnode/core/components/ui/button";
import { HrefLink } from "@vitnode/core/tanstack/link";
import { useTranslations } from "use-intl";
import { useArticlePermissions } from "./article-permissions";
/** Asked in the browser, so the cached page stays the same for everyone. */
const ArticleActions = ({ id }: { id: number }) => {
const t = useTranslations("newsroom.articles");
const { canEdit } = useArticlePermissions([id]).of(id);
return (
<div className="flex flex-wrap gap-2">
{canEdit ? (
<HrefLink
className={buttonVariants({ size: "sm", variant: "outline" })}
href={`/news/${id}/edit`}
>
{t("edit_link")}
</HrefLink>
) : null}
</div>
);
}; <header className="flex flex-col gap-2">
{/* title and date as before */}
<ArticleActions id={item.id} />
</header>Add "edit_link": "Edit" under newsroom.articles in src/locales/en.json.
of(id) reads all false until the answer arrives, so the link appears a moment after the page, and never for someone who may not use it.
Check the result
- Sign in as the member who wrote an article and open it. Edit shows below the date and opens the edit form.
- Open it as another member. There is no Edit.
- Open an article staff wrote in the AdminCP. Its
authoris empty, so only a moderator sees Edit.

The server still decides
The answer is a hint for the interface, not a lock. Every write route checks the session, the owner and the moderator permissions again, so a stale Edit, for example after an edit window closed, only leads to a refusal message. The flags also carry no reason; the write route says why when it refuses.
Keep the answer out of anything shared. Article pages are the same for every reader and can be cached; the permissions request is personal, never cached outside the tab, and keyed by the signed-in user.
Staff, moderators and members
Three kinds of permission meet on a content type:
| Who | Where they act | Granted in | Example |
|---|---|---|---|
| Staff | The AdminCP and its /admin/ routes | AdminCP ā Staff ā Administrators | newsroom:article:can_edit edits any article in the AdminCP |
| Moderators | The public site, the ModCP | AdminCP ā Staff ā Moderators | The moderator can_edit edits any article from the site |
| Members | The public site | Ownership, set by the server | The author edits their own article |
canEdit combines the last two. It is true for the owner while the rules allow it, and for a moderator with can_edit. An AdminCP session never adds powers on the public site. Moderators edit with the same forms as members, so one edit page serves both.
Lists and threads
On a page with many records, pass every id on screen to one hook call and read each row with of(id). The hook splits more than 50 ids into batches, so a long thread works too:
const permissions = useArticlePermissions(
articles?.edges.map(article => article.id) ?? [],
);
permissions.of(article.id).canEdit;Permissions API has the route's contract, every option and return value of the hook, and when to invalidate it. Next, let readers flag a problem with Reports.