Duplicate content
Let editors copy a VitNode Content Engine record into a new draft, with its translations, relations and repeatable rows, from the AdminCP or from your own code.
Duplication copies one record into a new draft: every field, every translation, relations in their order and repeatable rows. The blog uses it so an editor can start a new article from a similar one, or prepare a rewrite of a published article without touching the live version.
Turn on duplication
Add duplication: true to the definition. The blog's article content type does exactly that:
export const blogPostContentType = defineContentType({
id: "blog.post",
tableName: "blog_posts",
publication: true,
duplication: true,
// ...
});Duplication adds no column, so there is nothing to migrate, and no new permission. Duplicating needs can_create and can_view, because it creates a record by reading another. For the blog those are Create articles and View articles list. Run pnpm build:plugins and restart pnpm dev.
Duplicate an article in the AdminCP
Open AdminCP → Blog → Articles, open the More actions menu of an article and choose Duplicate. Confirm with Yes, duplicate it:

The list refreshes, a toast says the article was duplicated as a draft, and the copy opens on its edit page (or in the edit dialog, for a content type whose admin.edit.mode is "dialog"). If some of the source's languages are switched off on this site, a second toast lists the ones that were left out.
Duplicating the blog's "Hello world", published in English and Polish with its categories and authors, creates a draft with:
| Language | Title | Slug |
|---|---|---|
| English | Hello world (Copy) | hello-world-copy |
| Polish | Witaj świecie (Copy) | witaj-swiecie-copy |
The copy has the same categories and the same authors in the same order, and each language starts its own revision history.
What the copy contains
| Copied | Not copied |
|---|---|
| Every declared field, including rich text, groups and blocks | Revisions, schedules and preview links |
| Repeatable rows, as new rows in the same order | Slug history and redirects |
| Relation, user and file references, in their order. Files are reused, not uploaded again | Published state and publishedAt: the copy is a draft |
| Every translation, each as a draft with its own first revision | Hidden state: the copy is never hidden |
The copy gets a new id, new timestamps and version 1. Everything happens in one database transaction, so a failure leaves nothing behind. The source is only read, so its history gets no new revision.
Titles and slugs
When admin.titleField is a field.text(), the copy's title gets a suffix in each language, such as "Hello world (Copy)". The source title is shortened when the suffix would push it past the field's maxLength. The suffix is the core API message core.content.duplicate.title_suffix, resolved in each copied language and falling back to "(Copy)".
Turn the suffix off with duplication: { titleSuffix: false }. titleSuffix: true with a title field that is not a text field throws when the content type is defined.
Each slug is regenerated from the copy's title, so hello-world becomes hello-world-copy. A slug without a source gets -copy appended. When that slug is taken, by a live record or by another record's old URL, the engine tries -2, -3 and so on, up to 20 candidates.
Unique fields
A field.text({ unique: true }) cannot keep the source's value. Duplicating such a record without a new value fails before anything is written, with 422 and the code CONTENT_DUPLICATE_UNIQUE_REQUIRED. Pass the new value as an override from the API, as shown below.
Duplicate from the API
The admin module of a content type with duplication has POST /{id}/duplicate. For the blog, the module is named after its permission module, posts:
POST /api/@vitnode/blog/admin/content/posts/701/duplicate
Content-Type: application/json
{
"translations": {
"en": { "title": "Hello again", "friendlyUrl": "hello-again" },
"pl": { "title": "Witaj ponownie" }
}
}The body is optional:
| Name | Type | Description |
|---|---|---|
overrides | Partial shared field values | Values the copy takes instead of the source's. A group is merged leaf by leaf. Validated like a create |
translations | { [locale]: partial localized values } | Localized values per copied language. A language the source has no translation in is a 400 |
A slug you pass is used as it is, without -copy, and a title you pass is used without the "(Copy)" suffix. In the example above, the English copy is "Hello again" at hello-again, and the Polish copy is "Witaj ponownie" with the slug witaj-ponownie generated from that title.
The response is 201 with { id, sourceId, row, locales, skippedLocales }: the copy's id, the source id, the new row, the copied languages and the languages left out because they are switched off.
| Status | When |
|---|---|
400 | An invalid id, override or translation |
403 | Missing can_create or can_view |
404 | The source does not exist |
409 | No free slug was found (CONTENT_DUPLICATE_SLUG_CONFLICT, with field, locale and slug), or an override collides with another record |
422 | A unique field needs an override (CONTENT_DUPLICATE_UNIQUE_REQUIRED, with fields) |
Duplicate from your own code
service(c).duplicate(sourceId, options) and editorialService(c, { pluginId }).duplicate(sourceId, { actor, ...options }) take the same overrides and translations, plus tx. Both exist only when duplication is on, and both return null when the source does not exist.
The services do not emit events or index the copy. Run contentDuplicateEffects from @vitnode/core/content/server after the transaction commits, as the generated route does:
import { contentDuplicateEffects, resolveContentActor } from "@vitnode/core/content/server";
const { editorialService } = postContent;
if (!editorialService) throw new Error("Articles have no editorial features.");
const pluginId = CONFIG_PLUGIN.pluginId;
const actor = resolveContentActor(c);
const outcome = await editorialService(c, { pluginId }).duplicate(sourceId, {
actor,
});
if (outcome) {
await contentDuplicateEffects(
c,
postContent,
{ kind: "editorial", outcome },
{ actorUserId: actor.userId, pluginId },
);
}The effects emit content.blog.post.created with duplicatedFromId, one translation_created per copied language, and then content.blog.post.duplicated. They also add the copy to the search index as a private draft. See Events.
Check the result
Duplicate "Hello world" and go back to the list. "Hello world (Copy)" is a Draft, and /blog/hello-world-copy answers 404 until you publish it. Open the original's History and you'll find no new revision. As far as the original knows, nothing happened.