Edit form
Build a VitNode Content Engine edit page for authors and moderators that loads the editable record, handles sign-in and refusals, saves with the expected version through useContentPublicWrite and keeps the member's input on a version conflict.
This page builds /news/:id/edit, where an article's author, or a moderator, changes it. The page loads what may be edited, saves with the version it opened, and keeps the member's text when someone else saved first.
This continues the Create form. It reuses ArticleForm from src/pages/article-form.tsx, and needs editorial: true from Member writing, which gives every article a version. All the code goes into one new file, src/pages/article-edit-page.tsx; the steps build it in order, and the complete file is at the end.
Register the edit route
Edit src/routes.ts and add the page. This is an excerpt:
page("/news/:id/edit", {
component: lazy(() => import("./pages/article-edit-page")),
messages: ["newsroom.articles"],
}),There is no requires: "authenticated". The page shows its own sign-in prompt, because it must tell "not signed in" apart from "not yours". During a navigation the current page stays on screen with the top progress bar until the loader finishes.
Load the editable values
Create src/pages/article-edit-page.tsx and start it with the loader. GET /{id}/edit answers only for the author and moderators, drafts included:
// Answers only for the author and moderators, drafts included.
const fetchEditableArticle = async (id: number) =>
await fetcher({
plugin: manifest.id,
method: "get",
module: "content/news",
path: "/{id}/edit",
args: { params: { id } },
});
const loadEditableArticle = async (param: string) => {
const id = Number(param);
if (!Number.isSafeInteger(id) || id < 1)
return { state: "notFound" as const };
const res = await fetchEditableArticle(id);
if (res.status === 200) {
return { record: await res.json(), state: "ready" as const };
}
if (res.status === 401) return { id, state: "unauthenticated" as const };
if (res.status === 403) return { state: "forbidden" as const };
if (res.status === 404) return { state: "notFound" as const };
// A server failure is not "not found": let the app's error page show it.
throw new Error(`Loading article ${id} for editing failed.`);
};
type ArticleEditData = Awaited<ReturnType<typeof loadEditableArticle>>;
type EditableArticle = Extract<ArticleEditData, { state: "ready" }>["record"];
export const route = definePluginRoute({
load: async ({ params }) => await loadEditableArticle(params.id),
head: ({ t }) => ({
robots: "noindex, nofollow",
title: t("newsroom.articles.edit.title"),
}),
});The record has values (the editable fields), version, status and slug. TypeScript infers its type from the route, so EditableArticle needs no hand-written interface.
Handle each answer
At the end of the file, the page component turns each loader state into a screen:
const ArticleEditPage = ({
loaderData,
params,
}: PluginRoutePageProps<ArticleEditData>) => {
const t = useTranslations("newsroom.articles");
if (loaderData.state === "unauthenticated") {
return (
<div className="container mx-auto flex max-w-3xl flex-col gap-4 px-4 py-8">
<h1 className="text-2xl font-semibold text-balance">
{t("state.unauthenticated.title")}
</h1>
<HrefLink
className={buttonVariants({ className: "self-start" })}
href={`/login?returnTo=${encodeURIComponent(`/news/${loaderData.id}/edit`)}`}
>
{t("state.unauthenticated.action")}
</HrefLink>
</div>
);
}
if (loaderData.state !== "ready") {
const key = loaderData.state === "forbidden" ? "forbidden" : "not_found";
return (
<div className="container mx-auto flex max-w-3xl flex-col gap-2 px-4 py-8">
<h1 className="text-2xl font-semibold text-balance">
{t(`state.${key}.title`)}
</h1>
<p className="text-muted-foreground leading-relaxed">
{t(`state.${key}.desc`)}
</p>
</div>
);
}
return (
<div className="container mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8">
<h1 className="text-3xl font-semibold tracking-tight text-balance">
{t("edit.title")}
</h1>
<ArticleEditForm key={params.id} record={loaderData.record} />
</div>
);
};
export default ArticleEditPage;| Answer | Screen |
|---|---|
401 | The visitor is signed out. The page shows a sign-in link that comes back here |
403 | They can read the article but may not edit it |
404 | It does not exist, or it is a draft or hidden article that is not theirs. The page never confirms that it exists |
| Anything else | The loader throws, so a server failure shows the app's error page instead of "not found" |
key={params.id} remounts the form for another article, because the form reads version once.
Render the shared form
Between the loader and the page component goes ArticleEditForm. It fills ArticleForm with the saved values and sets up useContentPublicWrite with the article's version. Its onSubmit comes in the next step:
const ArticleEditForm = ({ record }: { record: EditableArticle }) => {
const t = useTranslations("newsroom.articles.edit");
const queryClient = useQueryClient();
const [current, setCurrent] = useState({
slug: record.slug,
status: record.status,
});
// AutoForm resets an untouched form to the defaults it renders with, so the
// defaults follow every save.
const [defaults, setDefaults] = useState({
body: record.values.body,
title: record.values.title,
});
const write = useContentPublicWrite({
fields: ARTICLE_FORM_FIELDS,
latestVersion: async () => {
const res = await fetchEditableArticle(record.id);
return res.status === 200 ? (await res.json()).version : null;
},
uniqueField: "title",
version: record.version,
});
// onSubmit, from the next step
return (
<>
{write.conflict}
<ArticleForm
defaultValues={defaults}
onSubmit={onSubmit}
submitLabel={t("submit")}
/>
{current.status === "published" && current.slug !== null ? (
<HrefLink
className={buttonVariants({
className: "self-start",
variant: "link",
})}
href={`/news/${current.slug}`}
>
{t("view")}
</HrefLink>
) : null}
</>
);
};View article links to the public page only while the article is published, because a draft's page answers 404.
Save with the expected version
Add onSubmit inside ArticleEditForm, where the comment says. write.submit sends PATCH /{id} with the version the form holds:
const onSubmit: AutoFormOnSubmit<ArticleFormSchema> = async (
values,
form,
) => {
const updated = await write.submit(
form,
async expectedVersion =>
await fetcher({
plugin: manifest.id,
method: "patch",
module: "content/news",
path: "/{id}",
args: {
params: { id: record.id },
body: { values, expectedVersion },
},
}),
);
if (!updated) return;
// the next step continues here
};expectedVersionis theversionthe form opened with. After a successful save,useContentPublicWritestores the new version itself, so the next save sends that one.write.submitreturns the record, ornullafter showing the reason: a field error under its field, a taken title under Title, or a toast for the rest, such as an expired session.
Update the form and the cached lists
Replace the comment at the end of onSubmit with what happens after a successful save:
setCurrent({ slug: updated.slug, status: updated.status });
setDefaults(values);
form.reset(values);
await queryClient.invalidateQueries({
queryKey: [manifest.id, "articles"],
});
toast.success(t("success.title"), {
description: t("success.desc", { title: values.title }),
});setDefaults(values)andform.reset(values)make the saved text the form's new starting point, so the form is no longer dirty.defaultslives in state becauseAutoFormresets an untouched form to thedefaultValuesit renders with; withoutsetDefaults, the old text would come back after a successful save.setCurrentkeepsslugandstatusfrom the answer, so View article follows the latest state.invalidateQueriesexpires the cached article list, whose query key starts with[manifest.id, "articles"], so/newsshows the new title.
Handle version conflicts
There is nothing extra to write. write.conflict, rendered above the form, is the conflict alert. When someone else saved after the form opened, PATCH /{id} answers 409 CONTENT_VERSION_CONFLICT, writes nothing, and the alert appears while the form keeps every character the member typed:
- Keep my version reads the current version with
latestVersion. The next Save changes replaces the other change. - Load latest version reloads the page and drops the unsaved text.
Add the labels
Add these keys under newsroom.articles in src/locales/en.json:
{
"newsroom": {
"articles": {
"edit": {
"title": "Edit article",
"submit": "Save changes",
"view": "View article",
"success": { "title": "Article saved", "desc": "\"{title}\" is up to date." }
},
"state": {
"unauthenticated": { "title": "Sign in to edit", "action": "Sign in" },
"forbidden": {
"title": "You can't edit this article",
"desc": "Only its author and moderators can edit it."
},
"not_found": {
"title": "Article not found",
"desc": "It does not exist, or you cannot see it."
}
}
}
}
}The complete file
import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form";
import { useQueryClient } from "@tanstack/react-query";
import { buttonVariants } from "@vitnode/core/components/ui/button";
import {
definePluginRoute,
type PluginRoutePageProps,
} from "@vitnode/core/routing";
import { useContentPublicWrite } from "@vitnode/core/tanstack/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { HrefLink } from "@vitnode/core/tanstack/link";
import { useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "use-intl";
import { manifest } from "@/manifest.gen";
import {
ARTICLE_FORM_FIELDS,
ArticleForm,
type ArticleFormSchema,
} from "./article-form";
// Answers only for the author and moderators, drafts included.
const fetchEditableArticle = async (id: number) =>
await fetcher({
plugin: manifest.id,
method: "get",
module: "content/news",
path: "/{id}/edit",
args: { params: { id } },
});
const loadEditableArticle = async (param: string) => {
const id = Number(param);
if (!Number.isSafeInteger(id) || id < 1)
return { state: "notFound" as const };
const res = await fetchEditableArticle(id);
if (res.status === 200) {
return { record: await res.json(), state: "ready" as const };
}
if (res.status === 401) return { id, state: "unauthenticated" as const };
if (res.status === 403) return { state: "forbidden" as const };
if (res.status === 404) return { state: "notFound" as const };
// A server failure is not "not found": let the app's error page show it.
throw new Error(`Loading article ${id} for editing failed.`);
};
type ArticleEditData = Awaited<ReturnType<typeof loadEditableArticle>>;
type EditableArticle = Extract<ArticleEditData, { state: "ready" }>["record"];
export const route = definePluginRoute({
load: async ({ params }) => await loadEditableArticle(params.id),
head: ({ t }) => ({
robots: "noindex, nofollow",
title: t("newsroom.articles.edit.title"),
}),
});
const ArticleEditForm = ({ record }: { record: EditableArticle }) => {
const t = useTranslations("newsroom.articles.edit");
const queryClient = useQueryClient();
const [current, setCurrent] = useState({
slug: record.slug,
status: record.status,
});
// AutoForm resets an untouched form to the defaults it renders with, so the
// defaults follow every save.
const [defaults, setDefaults] = useState({
body: record.values.body,
title: record.values.title,
});
const write = useContentPublicWrite({
fields: ARTICLE_FORM_FIELDS,
latestVersion: async () => {
const res = await fetchEditableArticle(record.id);
return res.status === 200 ? (await res.json()).version : null;
},
uniqueField: "title",
version: record.version,
});
const onSubmit: AutoFormOnSubmit<ArticleFormSchema> = async (
values,
form,
) => {
const updated = await write.submit(
form,
async expectedVersion =>
await fetcher({
plugin: manifest.id,
method: "patch",
module: "content/news",
path: "/{id}",
args: {
params: { id: record.id },
body: { values, expectedVersion },
},
}),
);
if (!updated) return;
setCurrent({ slug: updated.slug, status: updated.status });
setDefaults(values);
form.reset(values);
await queryClient.invalidateQueries({
queryKey: [manifest.id, "articles"],
});
toast.success(t("success.title"), {
description: t("success.desc", { title: values.title }),
});
};
return (
<>
{write.conflict}
<ArticleForm
defaultValues={defaults}
onSubmit={onSubmit}
submitLabel={t("submit")}
/>
{current.status === "published" && current.slug !== null ? (
<HrefLink
className={buttonVariants({
className: "self-start",
variant: "link",
})}
href={`/news/${current.slug}`}
>
{t("view")}
</HrefLink>
) : null}
</>
);
};
const ArticleEditPage = ({
loaderData,
params,
}: PluginRoutePageProps<ArticleEditData>) => {
const t = useTranslations("newsroom.articles");
if (loaderData.state === "unauthenticated") {
return (
<div className="container mx-auto flex max-w-3xl flex-col gap-4 px-4 py-8">
<h1 className="text-2xl font-semibold text-balance">
{t("state.unauthenticated.title")}
</h1>
<HrefLink
className={buttonVariants({ className: "self-start" })}
href={`/login?returnTo=${encodeURIComponent(`/news/${loaderData.id}/edit`)}`}
>
{t("state.unauthenticated.action")}
</HrefLink>
</div>
);
}
if (loaderData.state !== "ready") {
const key = loaderData.state === "forbidden" ? "forbidden" : "not_found";
return (
<div className="container mx-auto flex max-w-3xl flex-col gap-2 px-4 py-8">
<h1 className="text-2xl font-semibold text-balance">
{t(`state.${key}.title`)}
</h1>
<p className="text-muted-foreground leading-relaxed">
{t(`state.${key}.desc`)}
</p>
</div>
);
}
return (
<div className="container mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8">
<h1 className="text-3xl font-semibold tracking-tight text-balance">
{t("edit.title")}
</h1>
<ArticleEditForm key={params.id} record={loaderData.record} />
</div>
);
};
export default ArticleEditPage;Check the result
Sign in as the member who wrote an article in the Create form, and open /news/{id}/edit with its id. Permissions adds an Edit link to the article page next.

- Change the title and click Save changes. Article saved appears and the form keeps the new title. The slug stays the same, so links keep working, and
/newsshows the new title. - Open the same edit page in a second tab and save a change there. Then save in the first tab. Someone else saved in the meantime appears and your text stays. Click Keep my version, then Save changes.
- Open the edit URL as another member, and you get You can't edit this article. Signed out, you get Sign in to edit, even for an id that does not exist, because the server checks the session first. Signed in, an id that does not exist shows Article not found.

When edits wait for a moderator
Newsroom articles are not filed in a container, so a 200 from PATCH /{id} always means the change is live. A content type filed in a container whose publishing rules hold edits for approval answers 200 with approval.status: "pending" instead, and the live record stays as it was. Approval notices adds the pieces such a page needs.
Next, show the Edit link only to the people who may use it with Permissions.