Migrating from ngx-formly to ng-forge

A migration reference for moving an Angular dynamic-forms app from ngx-formly to ng-forge. Angular Signal Forms (@angular/forms/signals) is the new built-in forms substrate as of Angular 21; ng-forge is built on it, ngx-formly is built on Reactive Forms. That substrate change is the load-bearing difference everything else here flows from. The surface shape is similar — a config object describes the form, the library renders it — so most concepts map directly.

Should you migrate?

Your situation What to do
Standardising on Angular Signal Forms as the form substrate Migrate — formly is Reactive Forms, ng-forge is Signal Forms
Want schema-validation-first design (Zod, Valibot, ArkType) Migrate — built in via Standard Schema
Hitting performance limits on large forms or array sections Migrate — see Performance below for the substrate reasons
Heavy reliance on community formly extensions or custom field types Evaluate — the porting cost may outweigh the substrate benefits
Need a stable, low-churn API today Stay — ng-forge is younger; formly is mature
Use a UI library ng-forge does not ship (Kendo, NG-ZORRO, NativeScript) Build an adapter — see Building an Adapter

At a glance

ngx-formly ng-forge
FormlyFieldConfig FieldConfig (registered field type)
props (was templateOptions) UI-adapter-specific keys live in props; validation and labelling (label, required, email, min, max, minLength, maxLength, pattern, placeholder) live at the top level
expressions: { hide: '!model.x' } logic: [{ type: 'hidden', condition: { … } }] (structured)
expressionProperties (deprecated v6) logic array + derivation for values
validators / asyncValidators validators[] on the field + customFnConfig.{validators,asyncValidators,httpValidators} on the form
wrappers: ['form-field'] wrappers: [{ type: 'card', … }] (config objects)
props.addonLeft / props.addonRight (Bootstrap; demo-only on Material) addons: [{ slot, kind, … }] (universal across all 4 adapters, JSON-safe, preset actions)
fieldGroup (object) type: 'group' with fields: [...]
fieldArray (custom repeat type) type: 'array' (built in; verbose form with explicit add/remove fields, or simplified form with template + auto-buttons)
hooks: { onInit, onChanges, … } Angular component lifecycle inside custom field components, plus EventBus / EventDispatcher for cross-field events
FormlyJsonschema.toFieldConfig(schema) standardSchema(zodSchema) (different paradigm — see below)
[model] two-way binding [(value)] two-way binding (Angular model() signal)

The default shifts from string-first (formly's expressions DSL, template options) to structured-config-first. Strings still exist in ng-forge as shorthand (derivation: 'formValue.x * formValue.y') and escape hatches (the javascript condition), but the typical condition or derivation is now a typed object the engine can analyse for dependencies, refactor safely, and run under strict CSP.

Setup

Install core + a UI theme, register them once at the application root.

Application bootstrap
// ngx-formly v7 (current standalone API)
import { ApplicationConfig } from '@angular/core';
import { provideFormlyCore } from '@ngx-formly/core';
import { withFormlyMaterial } from '@ngx-formly/material';

export const appConfig: ApplicationConfig = {
  providers: [
    provideFormlyCore(withFormlyMaterial()),
  ],
};

The package layout matches one-for-one: @ngx-formly/material@ng-forge/dynamic-forms-material, and so on for Bootstrap, PrimeNG, and Ionic.

Your first form

The component-side change is small: bind a config to a form directive instead of three inputs (form, fields, model).

Minimal working form
@Component({
  selector: 'app-contact',
  template: `
    <form [formGroup]='form' (ngSubmit)='onSubmit()'>
      <formly-form [form]='form' [fields]='fields' [model]='model'></formly-form>
      <button type='submit'>Submit</button>
    </form>
  `,
})
export class ContactComponent {
  form = new FormGroup({});
  model: { email?: string } = {};

  fields: FormlyFieldConfig[] = [
    {
      key: 'email',
      type: 'input',
      props: {
        label: 'Email',
        required: true,
        type: 'email',
      },
    },
  ];

  onSubmit() {
    if (this.form.valid) console.log(this.model);
  }
}

Three things to internalise:

  • label / required / email / min / max / minLength / maxLength / pattern / placeholder live at the top level of the field, not under props. props carries the rendered control's attributes (type: 'email', rows) and UI-adapter-specific options (appearance, hint for Material; equivalents elsewhere).
  • value seeds the initial value and the inferred typevalue: '' makes the field a string, value: 0 makes it a number. Optional; omit when the field is computed or hydrated from a backend.
  • as const satisfies FormConfig is how strong typing flows in. No decorator-style generics; the literal field shapes infer the form value.

Two-way binding is [(value)] (Angular model() signal); read-only access is (submitted), (events), or the formValue signal off a viewChild ref.

Field types

The names line up directly except for a few cases.

ngx-formly type ng-forge type Notes
input input HTML input type goes in props.type in both
textarea textarea props.rows in both
checkbox checkbox Single boolean
multicheckbox multi-checkbox Hyphenated in ng-forge
radio radio
select select options is top-level on the field in ng-forge
datepicker (Material) datepicker
slider / toggle (Material) slider / toggle
repeat (custom) array (built-in) Verbose (explicit fields[][] + button fields) or simplified (template + auto-buttons)
none page Multi-step container
none row Horizontal flex layout
none text Display-only label / heading
none submit / next / previous / addArrayItem / removeArrayItem Built-in action buttons; auto-disabled while the form is invalid

Selects. Formly takes options inside props; ng-forge takes options at the top level. The FieldOption shape is fixed at { value, label, disabled? } — if your data has custom keys, remap once at the source (data.map(d => ({ value: d.id, label: d.name }))) or use a targetProperty: 'options' derivation (see Async data).

Select with static options
{
  key: 'sport',
  type: 'select',
  props: {
    label: 'Sport',
    options: [
      { value: 'football', label: 'Football' },
      { value: 'basketball', label: 'Basketball' },
    ],
    valueProp: 'value',
    labelProp: 'label',
  },
}

Validators

Both libraries support shorthand validators (required, min, max, minLength, maxLength, pattern) and a registry for custom validators. In formly, field-level validator config is keyed by validator name with an expression function inside; in ng-forge it's an array of validator objects with a type discriminator. Custom validator functions live on the form config under customFnConfig, split into three pillars by execution model:

Sync
customFnConfig.validators

Local checks: regex, range, custom predicates.

(ctx) => { kind } | null
HTTP
customFnConfig.httpValidators

"Ping the server with this value" — uniqueness, availability, address lookup.

{ request, onSuccess }
Async (resource)
customFnConfig.asyncValidators

Arbitrary Angular resource()-API workflows beyond plain HTTP.

{ params, factory, onSuccess }

Validator functions return only { kind } — the human-readable message is configured separately via validationMessages (per-field) or defaultValidationMessages (form-level), so the same kind can be localised consistently.

Custom validator
{
  key: 'ip',
  type: 'input',
  props: { label: 'IP Address', required: true },
  validators: {
    ip: {
      expression: (c: AbstractControl) =>
        !c.value || /(\d{1,3}\.){3}\d{1,3}/.test(c.value),
      message: 'Not a valid IP Address',
    },
  },
}

For HTTP-driven and async (resource()-API) validators, see Custom validators. For the full validation reference (built-in keys, message localisation, conditional validators), see Validation reference.

Conditional fields and dynamic props

Formly's expressions DSL — string-evaluated functions like '!model.country' — becomes ng-forge's structured logic array. More verbose, typed end-to-end, CSP-safe by default.

Hide a field based on another field's value
{
  key: 'parentEmail',
  type: 'input',
  props: { label: 'Parent Email', type: 'email' },
  expressions: {
    hide: 'model.age >= 18',
    'props.required': 'model.age < 18',
  },
}

For genuinely complex expressions, ng-forge has an opt-in javascript condition (a string evaluated against formValue) — see Conditional logic.

Hidden field values work differently. Formly defaults to actively resetting a field's value when it becomes hidden (via resetOnHide). ng-forge keeps the value live; the optional excludeValueIfHidden form option filters it out at submission time. If your code relied on the formly reset, port it as an explicit derivation that clears the value when the hide condition is true. See Hidden fields and the common pitfalls.

Cross-field validation

Formly puts cross-field validators on the parent fieldGroup. ng-forge does the same with a group field, but most users find Zod's .refine() / .superRefine() cleaner for this case:

import { z } from 'zod';
import { standardSchema } from '@ng-forge/dynamic-forms/schema';

const schema = z
  .object({
    password: z.string().min(8),
    passwordConfirm: z.string(),
  })
  .refine((d) => d.password === d.passwordConfirm, {
    message: 'Passwords must match',
    path: ['passwordConfirm'],
  });

const config = {
  schema: standardSchema(schema),
  fields: [
    { key: 'password', type: 'input', value: '', label: 'Password', props: { type: 'password' } },
    { key: 'passwordConfirm', type: 'input', value: '', label: 'Confirm', props: { type: 'password' } },
  ],
} as const satisfies FormConfig;

Custom field types

Both libraries register a Component against a name and let the field config reference it by name. ng-forge custom fields are plain standalone components with explicit input signals (no base class with magic getters); they receive Angular Signal Forms' FieldTree<T> and read/write via f().value() / f().valueChange.set(...). The library bridges your component to its internal state via a mapper (valueFieldMapper, optionsFieldMapper, etc.) — for most field shapes you reuse a built-in mapper.

See Adding custom fields for the full walkthrough.

Wrappers

Formly wrappers are components that include a <ng-container #fieldComponent> template marker. ng-forge wrappers do the same, but use viewChild.required('fieldComponent', { read: ViewContainerRef }) to expose the slot. Wrapper configuration is also more explicit: ng-forge wrappers are config objects ({ type: 'panel', title: 'Address' }) that carry their own props, so the wrapped field doesn't need to know it's wrapped.

See Writing a wrapper and Registering and applying.

Addons (prefix / suffix slots)

Formly's addon story is uneven across adapters — Bootstrap ships first-class addons via props.addonLeft / props.addonRight; Material has a demo wrapper users copy into their app; PrimeNG and Ionic have nothing built-in.

ng-forge unifies all four adapters under one shape: addons: [{ slot, kind, ... }]. The same slot vocabulary ('prefix' / 'suffix') works everywhere — adapters translate internally to their native projection mechanism. Buttons accept built-in preset actions (clear, reset, paste, copy, toggle-password-visibility) so common patterns require no handler code.

Bootstrap addons
// ngx-formly (Bootstrap — first-class)
{
  key: 'amount',
  type: 'input',
  props: {
    label: 'Amount',
    addonLeft: { class: 'bi bi-currency-euro' },
    addonRight: { text: 'EUR', onClick: (field, $event) => save(field) },
  },
}
Material addons
// ngx-formly (Material — demo wrapper only; users copy into their app)
{
  key: 'search',
  type: 'input',
  props: {
    label: 'Search',
    addonLeft:  { icon: 'search' },
    addonRight: { icon: 'close', onClick: (field) => field.formControl.setValue('') },
  },
}

What's different:

  • JSON-safe by default. ngx-formly's addon clicks are inline onClick(field, $event) => void functions — they can't live in JSON or a database. ng-forge addons are plain data; behavior is wired via actionRef: 'name' resolved against a registered handler map (provideAddonActions({...})), so configs round-trip through JSON.
  • Typed kind per adapter. ngx-formly addon props are typed loosely ({ icon?, text?, class?, onClick? }) and differ per adapter — Bootstrap uses class, Material's demo uses icon / text. ng-forge uses a discriminated union (mat-icon | mat-button | bs-icon | bs-button | prime-icon | prime-button | ion-icon | ion-button plus universal text | template | component) so the compiler knows which fields are valid for each adapter.
  • Universal slot vocabulary. ngx-formly mixes addonLeft / addonRight (Bootstrap, Material demo) with adapter-native slot names. ng-forge uses universal slot: 'prefix' | 'suffix' everywhere; the adapter translates that internally.
  • Preset actions. ng-forge ships clear, reset, paste, copy, toggle-password-visibility as declarative preset values. ngx-formly has nothing equivalent — every action is a hand-written callback.
  • First-class on all 4 adapters. ngx-formly only ships addons for Bootstrap; Material is demo-only, PrimeNG / Ionic have nothing. ng-forge ships first-class addons for all four with a single config shape.
  • Reactive hidden / disabled / loading. Each axis accepts boolean | Signal | Observable (DynamicValue). Neither ngx-formly's addon types nor its Material demo wrapper expose reactive visibility.

See Addons / Overview and Presets and Actions for the full surface.

Repeating sections / arrays

Formly has no built-in repeat type — apps register a custom FieldArrayType with add() / remove() handlers and a template. ng-forge ships type: 'array' directly.

Repeating array of items
// 1. Custom repeat type (every formly app needs this)
@Component({
  selector: 'formly-repeat',
  template: `
    <div *ngFor='let f of field.fieldGroup; let i = index'>
      <formly-field [field]='f'></formly-field>
      <button type='button' (click)='remove(i)'>Remove</button>
    </div>
    <button type='button' (click)='add()'>{{ props.addText }}</button>
  `,
})
export class RepeatType extends FieldArrayType {}

// 2. Register
provideFormlyCore({
  types: [{ name: 'repeat', component: RepeatType }],
});

// 3. Use
{
  key: 'tasks',
  type: 'repeat',
  props: { addText: 'Add Task' },
  fieldArray: {
    fieldGroup: [
      { key: 'name', type: 'input', props: { label: 'Task name', required: true } },
    ],
  },
}

The verbose form gives you placeable button fields (addArrayItem, prependArrayItem, insertArrayItem, removeArrayItem, popArrayItem, shiftArrayItem) — put the add button anywhere, including outside the array.

Multi-step wizards

Formly does not ship a wizard primitive — the canonical "stepper" example is a custom FieldType wrapping mat-stepper. ng-forge has a built-in page field type and next / previous button types that handle navigation, per-page validation, and disabled-when-invalid state.

{
  fields: [
    {
      key: 'account',
      type: 'page',
      fields: [
        { key: 'username', type: 'input', value: '', label: 'Username', required: true },
        { type: 'next', key: 'next1', label: 'Next' },
      ],
    },
    {
      key: 'profile',
      type: 'page',
      fields: [
        { key: 'firstName', type: 'input', value: '', label: 'First name' },
        {
          type: 'row',
          key: 'buttons',
          fields: [
            { type: 'previous', key: 'back', label: 'Back' },
            { type: 'submit', key: 'submit', label: 'Submit' },
          ],
        },
      ],
    },
  ],
} as const satisfies FormConfig;

The form value stays flat — pages are a layout / navigation concern, not a value-shape concern.

Async data and dynamic options

Reactive options loading — populate a select from an HTTP call, or one select from another's value — is canonical in formly via hooks.onInit subscribing to valueChanges. In ng-forge it's a derivation with a targetProperty of options:

Cascade: load departments after a company is picked
{
  key: 'department',
  type: 'select',
  props: { label: 'Department', options: [], valueProp: 'id', labelProp: 'name' },
  hooks: {
    onInit: (field) => {
      const company = field.parent.get('company').formControl;
      field.props.options = company.valueChanges.pipe(
        startWith(company.value),
        switchMap((id) => id ? this.api.departments(id) : of([])),
      );
    },
  },
}

The same http shape works for conditions (hide if a flag endpoint says so), value derivations (look up a price after the user enters a SKU), and property derivations (populate options).

Computed / derived values

Formly users typically write derived values via expressions setting model.<key> directly. ng-forge has a dedicated derivation shorthand and a structured derivation logic block.

Compute a total from quantity * unit price
{
  key: 'total',
  type: 'input',
  props: { label: 'Total', readonly: true },
  expressions: {
    'model.total': 'model.quantity * model.unitPrice',
  },
}

For non-trivial derivations (HTTP, multiple async sources, debounce), use the long-form logic: [{ type: 'derivation', … }] block, which supports source: 'http', dependsOn: [...], trigger: 'debounced', and condition to gate when it runs.

Schema validation and JSON-driven forms

Two related concerns: schema-driven validation (a schema enforces correctness) and JSON-driven form generation (the form structure itself comes from a serialized document, often a backend payload). ng-forge handles both.

Schema-driven validation. ngx-formly canonically uses JSON Schema for validation via @ngx-formly/core/json-schema's FormlyJsonschema.toFieldConfig(schema). ng-forge integrates with the Standard Schema spec — Zod, Valibot, ArkType, and any other library that implements it — via standardSchema(yourSchema).

JSON-driven form generation. ng-forge's FormConfig is itself a JSON-serializable document — you can author it as JSON, ship it from a backend, hydrate it on the client. There's no conversion step. If your backend currently emits JSON Schemas to drive formly, you have three options:

  1. Have the backend emit FormConfig directly — most ergonomic; your API now ships ng-forge schemas as JSON.
  2. Generate FormConfig from your OpenAPI 3.x spec at build time via @ng-forge/openapi-generator. Best when the spec is your source of truth.
  3. Run a one-time JSON Schema → FormConfig conversion during your migration. The two formats are structurally similar; a small adapter (~200 LOC) covers most apps.
Schema-driven validation
// JSON Schema → FormlyFieldConfig
import { FormlyJsonschema } from '@ngx-formly/core/json-schema';

const schema = {
  type: 'object',
  required: ['email'],
  properties: {
    email: { type: 'string', format: 'email' },
    age: { type: 'integer', minimum: 18, maximum: 120 },
  },
};

constructor(private formlyJsonschema: FormlyJsonschema) {
  this.fields = [this.formlyJsonschema.toFieldConfig(schema)];
}

Things that work differently

  • Strings → structured config. 'model.foo === "bar"' becomes a fieldValue condition object (or a javascript escape-hatch string for genuinely complex expressions).
  • Hooks → Angular lifecycle + EventBus. hooks.onInit / onDestroy map to effect() / OnDestroy inside a custom field. Cross-field coordination uses the EventBus / EventDispatcher.
  • Dependency tracking. fieldValue conditions and shorthand derivation strings auto-detect the field paths they read. Custom-function and HTTP variants need an explicit dependsOn: [...] because the engine can't introspect those bodies.
  • templateOptions cascading. Formly inherits props through defaultOptions and extends chains. ng-forge has form-level defaultProps and adapter-level defaults, but no per-type inheritance — type-level defaults become a wrapper or a custom field component instead.
  • Wrappers carry their own props. Wrapper config objects own their props ({ type: 'panel', title: 'Address' }), so the wrapped field doesn't need to know it has a wrapper.
  • parsers, modelOptions.debounce, focus: true. No direct per-field knobs. Workarounds: a self-targeting derivation with trigger: 'debounced' covers parsers-style transforms; consumers (conditions, derivations) debounce via debounceMs; programmatic focus is a viewChild + .nativeElement.focus() in the host component.
  • modelOptions.updateOn: 'blur' | 'submit'. No equivalent today — LogicTrigger only exposes 'onChange' | 'debounced', and the Signal Forms substrate commits on every change. If formly's commit-on-blur was load-bearing for your form, debouncing the consumers (validators, derivations) is the closest workaround. This is a hard wall, not a knob.

What ng-forge does NOT have an equivalent for

If any of these are blockers, decide upfront before starting the migration.

  • FormlyJsonschema.toFieldConfig(schema) as a runtime converter. No 1:1. See Schema validation for the three migration paths.
  • A community plugin / extension ecosystem. Formly has a long tail of community-built field types and extensions; ng-forge currently has only the four official UI adapters and the OpenAPI / MCP packages.
  • Two-or-more-dot prop paths in derivations. Formly's expressions: { 'props.config.foo': '…' } lets you compute any nested prop. ng-forge supports up to one level (options, label, disabled, props.minDate, …) and throws DynamicFormError at form-initialisation time if the path goes deeper — restructure your prop shape so the dynamic value sits at the top of props, or move the dynamic computation into a custom field component.
  • Custom valueProp / labelProp for selects. ng-forge's FieldOption is fixed at { value, label, disabled? }; remap source data with .map() or a targetProperty: 'options' derivation.
  • extensions API for cross-cutting field-construction hooks. Formly's extensions can mutate every field's config during construction. ng-forge has no public equivalent — those concerns become wrappers, custom field components, or build-time codegen.
  • Maturity at scale. ngx-formly has been in production for years; ng-forge is younger — fewer Stack Overflow answers, fewer copy-paste solutions on the long tail. The MCP server and Discord help, but they don't replace years of community-tested patterns.

OpenAPI generator

If your formly forms are driven by an OpenAPI 3.x spec, @ng-forge/openapi-generator is the closest analogue to formly's FormlyJsonschema pattern. It generates a FormConfig and the inferred form-value TypeScript type from the spec at build time, so submission handlers end up typed end-to-end. See the OpenAPI generator guide.

Performance

ng-forge is built for zoneless — every default component runs ChangeDetectionStrategy.OnPush and the value/validity/dirty-tracking layer is signal-driven, so there is no whole-tree change-detection cycle on every input event the way zone.js + Reactive Forms triggers one. The renderer also uses stable track keys on every @for, preserves field-instance identity across config updates so unchanged fields don't re-render, defers non-adjacent pages in multi-step forms via @defer (on idle), and updates arrays differentially (appending an item only renders the new one).

ngx-formly
value change
zone change-detection cycle
all expressions re-evaluateacross the whole field tree
ng-forge
value change
signal write
only dependents re-runscoped render via OnPush

Add stable track keys, reconcileFields, deferred non-adjacent pages, and append-only array updates on top.

FAQ

Can I run both libraries side by side during a migration? Yes. They don't conflict — different package names, different injection tokens, different component selectors. Install ng-forge, port one form at a time, deprecate formly only when nothing imports @ngx-formly/*.

Does ng-forge support Angular 21? Yes — ng-forge requires Angular 21+ as its baseline (signal-native APIs depend on it). If you're on Angular 20 or earlier, migrating to ng-forge implies an Angular upgrade first.

Does ng-forge use Reactive Forms (FormGroup / FormControl)? No. ng-forge is built on Angular Signal Forms (@angular/forms/signals), a separate system with a different primitive — FieldTree<T> — that holds value, validity, dirty/touched state, and errors as signals. There is no FormGroup or FormControl involved. If your formly app exposes a formGroup to consumers (template parents, services, etc.), those touch-points have no direct equivalent and need to be rewritten against the Signal Forms surface.

How do I report bugs / get help? Open an issue at github.com/ng-forge/ng-forge or join Discord. For evaluation help, the MCP server lets an LLM in your IDE scaffold configs for you.

Migration checklist

An order that works for porting a non-trivial app:

  1. Audit blockers against What ng-forge does NOT have an equivalent for. Decide upfront whether you'll work around or stay.
  2. Install ng-forge and the matching UI adapter (see Setup). Both libraries can coexist.
  3. Port one read-only form first — typically a settings page or a profile form. Fewer moving parts; easier sanity check.
  4. Translate validators next. Centralise custom validators on a single customFnConfig object you can import everywhere.
  5. Translate forms in dependency order. Forms with no cross-form coupling first; complex multi-step / array-heavy ones last.
  6. Remove formly when nothing imports @ngx-formly/*. pnpm uninstall @ngx-formly/core @ngx-formly/ and clean up provideFormlyCore.

Rough effort estimate. Plan ~half a day per simple form (a login, a profile, a contact form), and ~1–2 days per non-trivial form (multi-step wizards, custom field types, schema-driven forms with non-trivial cross-field validators, anything with custom wrappers or extension hooks). Plus an extra pass for performance verification on array-heavy forms — this is one of the cases where you should see meaningful improvement coming from formly. There is no automated migration tool today; port manually.

Next steps