Twenty is a self-hostable CRM aiming at the Salesforce market. That part is unremarkable. What's not: the backend generates its entire GraphQL schema — types, resolvers, filters, sorting — dynamically at runtime based on object metadata stored in Postgres. You define a custom object in the UI, and a findMany query appears on the API seconds later.
Why I starred it
Most "build your own CRM" tools are either NoCode wrappers around a fixed schema, or they ask you to write code to extend the data model. Twenty takes a third path: the data model is stored as data, and the GraphQL API is a live projection of that data.
That's a genuinely unusual architecture choice for a production SaaS app. Most teams reach for codegen (Prisma, graphql-codegen, etc.) and rebuild on schema change. Twenty skips all of that.
How it works
The monorepo is Nx with about 20 packages. The interesting parts live in packages/twenty-server, a NestJS application.
The metadata layer
Every workspace gets its own Postgres schema (workspace_{uuid}). Object definitions — what Salesforce calls SObjects — are stored in a fieldMetadata table in the core schema. Here's the entity (packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts):
@Entity('fieldMetadata')
@Unique('IDX_FIELD_METADATA_NAME_OBJECT_METADATA_ID_WORKSPACE_ID_UNIQUE', [
'name',
'objectMetadataId',
'workspaceId',
])
export class FieldMetadataEntity<
TFieldMetadataType extends FieldMetadataType = FieldMetadataType,
> extends SyncableEntity {
@Column({ nullable: false, type: 'varchar' })
type: TFieldMetadataType;
@Column({ nullable: false })
name: string;
@Column({ nullable: true, type: 'jsonb' })
defaultValue: JsonbProperty<FieldMetadataDefaultValue<TFieldMetadataType>>;
Field types (UUID, TEXT, DATE_TIME, BOOLEAN, NUMERIC, ARRAY, TS_VECTOR, ...) are part of a shared enum in twenty-shared. When a workspace admin adds a new field, a row lands in fieldMetadata. The workspace schema DDL is locked during hot upgrades (WORKSPACE_SCHEMA_DDL_LOCKED env flag) to prevent concurrent migrations from tearing each other apart.
Schema generation at runtime
packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/ is where the metadata becomes types. The TypeMapperService (services/type-mapper.service.ts) maintains a plain Map<FieldMetadataType, GraphQLScalarType>:
private readonly baseTypeScalarMapping = new Map<
FieldMetadataType,
GraphQLScalarType | GraphQLList<GraphQLScalarType>
>([
[FieldMetadataType.UUID, UUIDScalarType],
[FieldMetadataType.TEXT, GraphQLString],
[FieldMetadataType.DATE_TIME, GraphQLISODateTime],
[FieldMetadataType.BOOLEAN, GraphQLBoolean],
[FieldMetadataType.NUMERIC, BigFloatScalarType],
[FieldMetadataType.ARRAY, StringArrayScalarType],
[FieldMetadataType.TS_VECTOR, TSVectorScalarType],
]);
The workspace schema factory reads object metadata from the cache, iterates fields, maps each FieldMetadataType to a GraphQL type, and assembles the full schema on the fly. This runs on each workspace context switch, not on deploy.
The workspace datasource service (workspace-datasource.service.ts) does the multi-tenancy plumbing: each workspace ID maps to a Postgres schema name, and the service creates/drops schemas as workspaces are provisioned or deleted. DDL changes are gated behind a checkSchemaExists() guard before any migration runs.
Workflow engine
The automation layer (packages/twenty-server/src/modules/workflow/) has a workflow executor that dispatches to typed action handlers. The code action handler (workflow-actions/code/code.workflow-action.ts) delegates to a LogicFunctionExecutorService:
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: workflowActionInput.logicFunctionId,
workspaceId,
payload: workflowActionInput.logicFunctionInput,
});
Other action types: http-request, mail-sender, record-crud, if-else, iterator, delay, ai-agent. The ai-agent action type is recent — visible in the directory structure alongside tool-executor-workflow-action.ts. They're building toward LLM-triggered automations inside the CRM itself.
Front-end
React with Jotai for state, Linaria for CSS-in-JS, Lingui for i18n. The front-end package (twenty-front) has a generated/ directory with Apollo hooks auto-generated from the dynamic schema — so even the client side codegen reflects the live metadata. That's a nice touch: the schema shape is stable enough to codegen against, even though it changes per workspace.
Using it
Self-hosted via Docker Compose:
curl -sL https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/docker-compose.yml \
-o docker-compose.yml
curl -sL https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/.env.example \
-o .env
docker compose up -d
The metadata GraphQL API is separate from the workspace API (metadata-graphql-api.module.ts vs core-graphql-api.module.ts). Creating a custom object looks like:
mutation CreateOneObjectMetadataItem($input: CreateOneObjectInput!) {
createOneObject(input: $input) {
id
nameSingular
namePlural
fields {
id
name
type
}
}
}
After that mutation resolves, findManyYourObject is available on the workspace API.
Rough edges
The stack is heavy. Self-hosting requires Postgres, Redis, and the NestJS server — no sqlite fallback, no single-binary option. For a small team running this internally, that's a real ops burden.
The twenty-orm package is a custom ORM layer on top of TypeORM, adding workspace-aware repository patterns. It works, but it means contributors need to learn a layer that doesn't exist elsewhere. Documentation for this layer is thin.
Test coverage is spotty. packages/twenty-server/src/engine/api/graphql/__tests__/ exists but is not comprehensive given the complexity of the schema generation pipeline. The parts most likely to break — dynamic schema assembly, workspace migrations — are the least covered.
The twenty-companion and twenty-apps packages are early. They're in the monorepo but there's not much to them yet.
Bottom line
If you want a self-hostable CRM where the data model evolves without touching code or running migrations manually, Twenty is the only serious option I've seen. The dynamic GraphQL generation is the right call for this use case — and it's implemented cleanly enough that extending it doesn't require understanding all of it first.
