Logo VitNode

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:

plugins/example/src/content/tag.ts
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

StrategyPostgres keyIn JSON and URLsIn TypeScript
"serial" (default)serial, up to 2,147,483,647A number: 42number
"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,807A decimal string: "9007199254740994"string

Choose by what the ids are for:

  • serial suits most content. Ids are short and readable in URLs and logs. The blog's articles and categories use it.
  • uuid suits content whose ids should not be guessable or countable, or records created in several places that must not collide. The example plugin's example.tag uses it.
  • bigint suits tables that may outgrow two billion rows, such as events or logs. The example plugin's example.event uses 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"
Response (trimmed)
{
  "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:

StrategyAcceptedRefused, for example
serialA positive integer up to 2,147,483,647, or its digits in a URLabc, 0012, -1
bigintPositive decimal digits up to 9,223,372,036,854,775,807, without a sign or leading zeros0012, -1, 9223372036854775808
uuidLowercase, hyphenated 8-4-4-4-12 hexAn 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 uuid foreign key and a bigint-to-uuid junction 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) itemId column. See Database.
  • Events type contentId by strategy: number for serial, string for 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 serial ids, whatever the owner uses. User and file references stay integers.

Helpers

Import these from @vitnode/core/content when your own code handles ids:

HelperReturnsUse it to
ContentIdOf<typeof eventContentType>number or stringType an id of one content type
ContentIdnumber | stringType an id in code that handles any content type
parseContentIdOf(definition, value)The id, or nullValidate an id from a request, a job payload or a URL
parseContentId(strategy, value)The id, or nullThe same, when you hold only the strategy
contentIdParamSchema(strategy)A Zod schemaValidate a path or query parameter. A serial id becomes a number
contentIdSchema(strategy)A Zod schemaValidate an id inside a JSON body
contentAnyIdSchemaA Zod schemaAccept an id of any strategy, never rounding a bigint
contentIdKey(id)stringStore an id in a text column, cache key or log. Serial ids keep their digits
contentIdFromKey(strategy, key)The id, or nullTurn a stored key back into an id
compareContentIds(strategy, a, b)numberSort 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:

  1. Add the new key column and fill it for every row.
  2. Convert every column that references the old key: to-one relation columns in other content types, junction tables (itemId and relatedItemId), the translations table's itemId and repeatable child tables' itemId.
  3. 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_documents and core_ai_translation_sources.
  4. Swap the primary key and foreign key constraints, then change idStrategy and 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.