Record ID strategies
Key VitNode Content Engine records by serial integers, UUIDs or bigint, and see how those ids look in Postgres, JSON, URLs, relations and your own code.
idStrategy decides the type of a content type's primary key. The default, "serial", is an auto-incrementing integer. "uuid" gives random ids that reveal nothing about how many records exist. "bigint" gives integers that will not run out. Pick it when you create the content type, because changing it later means a hand-written data migration, and nobody enjoys those.
The example plugin keys its tags by UUID:
export const tagContentType = defineContentType({
id: "example.tag",
tableName: "example_tags",
idStrategy: "uuid",
localization: { defaultLocale: "en", fallback: "default" },
publication: true,
fields: {
name: field.text({ localized: true, required: true, minLength: 1, maxLength: 100 }),
slug: field.slug({ localized: true, source: "name" }),
// category and relatedTags relations
},
// editorial, visibility, duplication, publicApi and admin
});Strategies
| Strategy | Postgres key | In JSON and URLs | In TypeScript |
|---|---|---|---|
"serial" (default) | serial, up to 2,147,483,647 | A number: 42 | number |
"uuid" | uuid, DEFAULT gen_random_uuid() | A lowercase string: "047d2c6c-1d86-47e2-bcd3-2df9f5359541" | string |
"bigint" | bigint GENERATED BY DEFAULT AS IDENTITY, up to 9,223,372,036,854,775,807 | A decimal string: "9007199254740994" | string |
Choose by what the ids are for:
serialsuits most content. Ids are short and readable in URLs and logs. The blog's articles and categories use it.uuidsuits content whose ids should not be guessable or countable, or records created in several places that must not collide. The example plugin'sexample.taguses it.bigintsuits tables that may outgrow two billion rows, such as events or logs. The example plugin'sexample.eventuses it.
Both example types appear in AdminCP → Example → Tags and Events, and their edit links carry the UUID or the decimal string, such as /admin/content/example/event/9007199254740994/edit.
Why bigint ids are strings
A JavaScript number holds integers exactly only up to 2^53 − 1 (9007199254740991). A bigint key can be much larger, and a larger id read as a number is silently rounded to a different record: Number("9007199254740993") is 9007199254740992. VitNode never converts a bigint id through Number. Drizzle reads the column in string mode, and every API, event, cursor and AdminCP URL carries the exact decimal string.
Once the example_events id sequence is past 2^53, the public API still returns every id intact:
curl "http://localhost:3000/api/@vitnode/example/content/events/big-launch"{
"id": "9007199254740994",
"title": "Big launch",
"slug": "big-launch",
"primaryTag": { "id": "047d2c6c-1d86-47e2-bcd3-2df9f5359541" },
"tags": ["047d2c6c-1d86-47e2-bcd3-2df9f5359541"]
}primaryTag and tags point at example.tag records, so they carry UUIDs. Keep bigint ids as strings in your own code too.
Valid ids
Every route that takes an id parses it with the content type's strategy before touching the database. Anything else answers 400, never a database error:
| Strategy | Accepted | Refused, for example |
|---|---|---|
serial | A positive integer up to 2,147,483,647, or its digits in a URL | abc, 0012, -1 |
bigint | Positive decimal digits up to 9,223,372,036,854,775,807, without a sign or leading zeros | 0012, -1, 9223372036854775808 |
uuid | Lowercase, hyphenated 8-4-4-4-12 hex | An uppercase UUID, not-a-uuid, 123 |
Each record has exactly one spelling, so one record is never cached or indexed under two keys.
Where ids appear
Every Content Engine feature works with all three strategies: the public API, the AdminCP lists, editors, pickers and edit links, public pages and sitemaps, previews, schedules, revisions, live editing, events, search, pagination cursors, duplicate and hide.
- Relations use the key type of the record they point at. A bigint event that links to uuid tags gets a
uuidforeign key and abigint-to-uuidjunction table. See Relations across id strategies. - Shared core tables that hold records of any content type, such as revisions, schedules, slug history, live editing drafts and the search index, store the id as text in a
varchar(64)itemIdcolumn. See Database. - Events type
contentIdby strategy:numberfor serial,stringfor uuid and bigint. - Sitemaps page through records in key order. For a uuid content type that order is lexical, not creation order, and every record still appears once.
- Repeatable rows keep their own
serialids, whatever the owner uses. User and file references stay integers.
Helpers
Import these from @vitnode/core/content when your own code handles ids:
| Helper | Returns | Use it to |
|---|---|---|
ContentIdOf<typeof eventContentType> | number or string | Type an id of one content type |
ContentId | number | string | Type an id in code that handles any content type |
parseContentIdOf(definition, value) | The id, or null | Validate an id from a request, a job payload or a URL |
parseContentId(strategy, value) | The id, or null | The same, when you hold only the strategy |
contentIdParamSchema(strategy) | A Zod schema | Validate a path or query parameter. A serial id becomes a number |
contentIdSchema(strategy) | A Zod schema | Validate an id inside a JSON body |
contentAnyIdSchema | A Zod schema | Accept an id of any strategy, never rounding a bigint |
contentIdKey(id) | string | Store an id in a text column, cache key or log. Serial ids keep their digits |
contentIdFromKey(strategy, key) | The id, or null | Turn a stored key back into an id |
compareContentIds(strategy, a, b) | number | Sort ids the way Postgres does |
CONTENT_SERIAL_MAX (2147483647) and CONTENT_BIGINT_MAX ("9223372036854775807") are exported too. A custom route that reads an id is shown in Services.
Change the strategy later
The strategy is part of the table's schema, and the engine does not convert existing data. Don't change idStrategy on a content type that has records and then run db:migrate. The generated SQL cannot turn integer keys into UUIDs.
To switch a live content type, write the data migration yourself:
- Add the new key column and fill it for every row.
- Convert every column that references the old key: to-one relation columns in other content types, junction tables (
itemIdandrelatedItemId), the translations table'sitemIdand repeatable child tables'itemId. - Rewrite the keys stored for that content type in the shared tables:
core_content_revisions,core_content_schedules,core_content_slug_history,core_search_index,core_content_drafts,core_content_field_locks,core_content_documentsandcore_ai_translation_sources. - Swap the primary key and foreign key constraints, then change
idStrategyand check that generating migrations again produces no further changes.
Old URLs and API clients that stored the previous ids stop working, so plan redirects if ids appear in public URLs. Creating a new content type and copying the records into it is often simpler.