@keystone-6/[email protected] and the related package versions introduce breaking changes across Keystone's configuration, Prisma, access control, auth and Admin UI. This guide explains how those changes affect
an existing project and how to update.
You can use this guide to upgrade from @keystone-6/core@6 or
@keystone-6/core@7 because the breaking changes in @keystone-6/core@7 were limited to
upgrading to next@15 and react@19.
Make sure you are using Node.js 20.19 or newer.
pnpm add @keystone-6/core@^8
# only upgrade the packages that you are using
pnpm add @keystone-6/auth@^10
pnpm add @keystone-6/fields-document@^11
pnpm add @keystone-6/cloudinary@^10
pnpm add @keystone-6/document-renderer@^2
@keystone-6/[email protected] requires projects to declare next, react and react-dom as
dependencies directly:
pnpm add next@^16 react@^19 react-dom@^19
Several dependencies used internally by @keystone-6/[email protected] have also moved to new
major or minimum versions. Only update these packages when you already declare them in your package.json.
graphql@^16.14.2express@^5@apollo/client@^4@apollo/server@^5@graphql-ts/schema@^1@graphql-ts/extend@^2Install both Prisma packages:
pnpm add @prisma/client@^7.9
pnpm add --save-dev prisma@^7.9
Then install the adapter/driver pair for each database provider the project builds or tests against:
| Provider | Install |
|---|---|
| SQLite | pnpm add @prisma/adapter-better-sqlite3@^7.9 better-sqlite3@^12.6 |
| PostgreSQL | pnpm add @prisma/adapter-pg@^7.9 pg@^8.16 |
| MySQL | pnpm add @prisma/adapter-mariadb@^7.9 mariadb@^3.4 |
Projects with custom Admin UI code must replace @keystone-ui/* with @keystar/ui.
pnpm add @keystar/[email protected]
If you recieve a peer dependency warning, you should use the newer version
Keystone no longer supplies named local or S3 storage strategies. If your existing
configuration has a top-level storage property or an image/file field, install the libraries needed to implement that strategy in application
code.
For example, an S3 implementation can use:
pnpm add @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner
A local filesystem implementation can use Node's built-in node:fs APIs and needs no
storage package.
prisma@7Replace Keystone's removed runtime connection options:
// before
db: {
provider: 'postgresql',
url: process.env.DATABASE_URL!,
shadowDatabaseUrl: process.env.SHADOW_DATABASE_URL,
enableLogging: true,
}
with the adapter for the configured provider:
import { PrismaPg } from '@prisma/adapter-pg'
db: {
provider: 'postgresql',
prismaClientOptions: () => ({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }),
log: ['query'],
}),
}
SQLite:
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
db: {
provider: 'sqlite',
prismaClientOptions: () => ({
adapter: new PrismaBetterSqlite3({ url: process.env.DATABASE_URL! }),
}),
}
MySQL:
import { PrismaMariaDb } from '@prisma/adapter-mariadb'
db: {
provider: 'mysql',
prismaClientOptions: () => ({
adapter: new PrismaMariaDb(process.env.DATABASE_URL!),
}),
}
Create or update prisma.config.ts:
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'
export default defineConfig({
schema: 'schema.prisma',
migrations: { path: 'migrations' },
datasource: {
url: env('DATABASE_URL'),
// only necessary if you want to use a specific shadow database
shadowDatabaseUrl: env('SHADOW_DATABASE_URL'),
},
})
The runtime adapter and prisma.config.ts are separate:
db.prismaClientOptions().prisma CLI uses prisma.config.ts.Keystone now generates Prisma Client into generated/prisma by default:
// before
import { PrismaClient } from '@prisma/client'
// after
import { PrismaClient } from './generated/prisma/client'
The default generated Keystone types path has also changed to generated/keystone/types:
// before
import type { Lists } from '.keystone/types'
// after
import type { Lists } from './generated/keystone/types'
Update .gitignore to ignore the generated directory.
Update scripts:
| Before | After |
|---|---|
keystone prisma <args> | prisma <args> |
keystone migrate ... | an explicit Prisma migration command |
keystone start --with-migrations | a separate prisma migrate deploy release step, then keystone start |
Remove keystone postinstall --fix, the --fix flag no longer exists.
field.label becomes field.ui.labellist.description and list.ui.description are removedlist.graphql.descriptionMove list and field-group defaults:
// before
ui: {
itemView: { defaultFieldMode: 'read' },
}
// after
fieldDefaults: {
ui: {
itemView: { fieldMode: 'read' },
},
}
Apply the same change for createView and itemView.
getAdditionalFilesReplace an array of functions/nested arrays with one function that returns a flat array:
// before
ui: {
getAdditionalFiles: [filesFromA, filesFromB],
}
// after
ui: {
getAdditionalFiles: async () => [
...(await filesFromA()),
...(await filesFromB()),
],
}
For every relationship using ui.displayMode: 'count', set:
ui: {
displayMode: 'count',
itemView: { fieldMode: 'read' },
}
A dynamic mode may return only 'read' or 'hidden'.
Custom field-view functions must also handle a nullable item argument in create or
missing-item states.
If the old Keystone configuration has top-level named storage:
export default config({
storage: {
uploads: {
kind: 's3',
type: 'file',
// provider options
},
},
// ...
})
attachment: file({ storage: 'uploads' })
remove the top-level storage property and give each file() or image() field a
strategy object:
attachment: file({
storage: {
async put(key, stream, meta, context) {
// Upload the stream with the provider SDK.
},
async delete(key, context) {
// Delete the object with the provider SDK.
},
url(key, context) {
// Return a public or signed URL.
},
},
})
The new StorageStrategy consists directly of put, delete and url. It does not
accept the old kind, type, bucket or local path configuration. Reuse a shared
strategy object when several fields intentionally have identical behaviour.
See the local and s3 examples for more details on how you could implement this.
Replace validateInput and validateDelete with operation-specific validate hooks:
hooks: {
validate: {
create: createOrUpdateValidation,
update: createOrUpdateValidation,
delete: deleteValidation,
},
}
Replace bcrypt and workFactor:
import bcryptjs from 'bcryptjs'
password({
kdf: {
hash: secret => bcryptjs.hash(secret, 12),
compare: (secret, hash) => bcryptjs.compare(secret, hash),
},
})
If neither old option was set, omit kdf; Keystone defaults to bcrypt with work factor 10. Existing bcrypt hashes remain compatible with that default.
The default bcrypt KDF now rejects inputs longer than 72 UTF-8 bytes where previously they were silently truncated by bcrypt.
access.read now applies to filtering and orderingIn @keystone-6/[email protected], a single field access.read function applies to item reads,
filtering and ordering. Previously, field read access did not affect filtering or
ordering, which were controlled separately by isFilterable and isOrderable.
In most projects, the new behavior is the better default: if someone cannot read a field, they usually should not be able to infer information about it by filtering or ordering. When that matches what you want, keep the read function as is:
access: {
read: canReadItem,
},
| Old value | New access | New graphql.omit |
|---|---|---|
absent or true | allowAll | not omitted |
false | denyAll | graphql.omit.read.filter/order: true |
| function | the same function under access.read.filter/order | not omitted |
For example, this preserves an explicit filtering rule and removes ordering from the GraphQL schema:
// before
text({
access: { read: canReadItem },
isFilterable: canFilter,
isOrderable: false,
})
// after
text({
access: {
read: {
item: canReadItem,
filter: canFilter,
order: denyAll,
},
},
graphql: {
omit: {
read: {
item: false,
filter: false,
order: true,
},
},
},
})
Move defaultIsFilterable and defaultIsOrderable into fieldDefaults.access.read and
fieldDefaults.graphql.omit.read using the same mapping.
sudo and internal scopecontext.sudo() bypasses access control and graphql.omit.
context.internal() bypasses graphql.omit but retains access control.
The context.sudo().graphql.schema shape now retains access-control instrumentation.
Code that inspected a schema extension changes from:
schema.extensions.sudo
to:
schema.extensions.scope === 'internal'
@keystone-6/auth removes:
magicAuthLink;passwordResetLink;initFirstItem;/init Admin UI page; andcreateInitial* mutation.See the magic link example for how you could implement magic link & password reset flows in your own project.
db.onConnect can seed development data to replace initFirstItem:
db: {
async onConnect(context) {
const sudo = context.sudo()
if (await sudo.db.User.count()) return
// Create a development-only account with a random password
},
}
This pattern is only suitable for development seeding.
endSession@keystone-6/core no longer adds endSession when context.session.end exists.
@keystone-6/auth adds it when required. If you're not using @keystone-6/auth and want to keep the endSession operation, add a schema extension for it.
graphql with a context-bound gThe graphql export from @keystone-6/core has been renamed to g. The old name is
still exported as a deprecated alias, but new and migrated code should use
gWithContext so resolvers receive the project's generated context type:
import { gWithContext } from '@keystone-6/core'
import type { Context } from './generated/keystone/types'
const g = gWithContext<Context>()
type g<T> = gWithContext.infer<T>
Rename runtime calls such as graphql.object, graphql.field and graphql.arg to
g.object, g.field and g.arg.
The type-level migration is not a direct namespace rename. Types such as
graphql.ObjectType<Source> are replaced by the g<T> inference helper applied
to the corresponding builder:
// before
type Result = graphql.ObjectType<Source>
// after
type Result = g<typeof g.object<Source>>
Use the same pattern for other functions where the old code referred to a type
through the graphql namespace. See graphql-ts.com for more details on this API.
Computed GraphQL plurals now preserve original casing. A new list.graphql.singular
option can set singular output and operation names.
After generating schema.graphql, compare it with the pre-upgrade version. For each
changed public name either:
list.graphql.singular or list.graphql.plural to preserve an existing API when
preserving it is an explicit project rule; orselect({ type: 'enum' }) and multiselect({ type: 'enum' }) no longer singularise
derived enum names. This affects fields with plural keys. For example:
User: list({
fields: {
roles: select({
type: 'enum',
options: ['admin', 'editor'],
}),
},
})
Previously, Keystone singularised the roles field key when generating its enum name:
enum UserRoleType {
admin
editor
}
It now preserves the plural field key:
enum UserRolesType {
admin
editor
}
The field key, column name and enum values are unchanged. Only the generated enum type
identifier changes from UserRoleType to UserRolesType.
Both field types use the new name in the GraphQL schema. Update checked-in GraphQL operations and generated types to the new name, or report the API decision.
For select({ type: 'enum' }), the same name is used for the generated Prisma enum on
PostgreSQL and MySQL:
// before
enum UserRoleType {
admin
editor
}
// after
enum UserRolesType {
admin
editor
}
On PostgreSQL this also changes the name of a database enum type and requires a migration. MySQL uses the Prisma enum name when generating the client, but stores the enum values directly in the column definition without a named database type, so this change produces no database migration. SQLite continues to store an enum select in a string column.
multiselect({ type: 'enum' }) continues to use Prisma Json storage on every provider,
so its enum-name change affects GraphQL only.
Direct context.prisma operations now throw Prisma errors instead of Keystone-wrapped
GraphQLError values.
Uncaught Prisma errors returned through GraphQL are masked with
code: 'KS_PRISMA_ERROR' and message: 'Prisma error'. A custom formatter belongs
under graphql.apolloConfig.formatError:
graphql: {
apolloConfig: {
formatError(formattedError, error) {
// Report the original error only to a trusted server-side service.
return formattedError
},
},
}
@keystone-ui/* with @keystar/uiAll usages of @keystone-ui/* should be replaced with @keystar/ui. The new package exports are not directly equivalent with the old one, so you will need to update imports and usage accordingly. See keystar.design for information on the new package and its usage. Note if you have a large amount of custom Admin UI code, this will likely be the most time-consuming part of the upgrade.
Remove imports from @keystone-6/fields-document/primitives, those internals are no
longer exported.
Replace the generic import with the provider-specific helper:
import { resetDatabase } from '@keystone-6/core/testing/sqlite'
await resetDatabase({ filename: databasePath }, migrationsDirectory)
PostgreSQL uses @keystone-6/core/testing/postgresql; MySQL uses
@keystone-6/core/testing/mysql.
The new helpers destroy the target test database and apply migration.sql
files.
prisma@7 changesCompare direct Prisma usage with the
official prisma@7 upgrade guide.
No database change is expected for most Keystone configuration migrations but you should review these specific cases:
SQLite projects using json, multiselect, document, cloudinary or a custom
jsonFieldTypePolyfilledForSQLite field change from Prisma String storage to Prisma
Json.
An existing SQLite database created through Prisma's former native driver may need:
adapter: new PrismaBetterSqlite3(
{ url: process.env.DATABASE_URL! },
{ timestampFormat: 'unixepoch-ms' }
)
For a plural select({ type: 'enum' }) field, Prisma may interpret the changed generated
enum identifier as replacing both the enum and its column. For example, changing
UserRoleType to UserRolesType can generate SQL equivalent to:
CREATE TYPE "UserRolesType" AS ENUM ('admin', 'editor');
ALTER TABLE "User" DROP COLUMN "roles";
ALTER TABLE "User" ADD COLUMN "roles" "UserRolesType" NOT NULL;
DROP TYPE "UserRoleType";
When only the generated enum type name changed, replace the generated operations with a PostgreSQL enum rename:
ALTER TYPE "UserRoleType" RENAME TO "UserRolesType";
Adjust the identifiers to match the generated Prisma schema and verify that the enum values and column definition are otherwise unchanged. MySQL requires no database migration for this since MySQL does not use named enum types.
{% related-content %} {% well heading="Database Migration" href="/docs/guides/database-migration" %} Create, review and deploy Prisma migration files after completing the source upgrade. {% /well %} {% well heading="System Configuration" href="/docs/config/config" %} Review the current Keystone configuration API. {% /well %} {% well heading="Auth and Access Control" href="/docs/guides/auth-and-access-control" %} Verify migrated access-control policy with a database. {% /well %} {% /related-content %}