diff --git a/conformance/provisioner/provisioner.yaml b/conformance/provisioner/provisioner.yaml
index 5a58889274..5862ee37ec 100644
--- a/conformance/provisioner/provisioner.yaml
+++ b/conformance/provisioner/provisioner.yaml
@@ -30,6 +30,13 @@ rules:
   - gatewayclasses/status
   verbs:
   - update
+- apiGroups:
+  - apiextensions.k8s.io
+  resources:
+  - customresourcedefinitions
+  verbs:
+  - list
+  - watch
 ---
 kind: ClusterRoleBinding
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/deploy/helm-chart/templates/rbac.yaml b/deploy/helm-chart/templates/rbac.yaml
index 405c34525e..ac49069f32 100644
--- a/deploy/helm-chart/templates/rbac.yaml
+++ b/deploy/helm-chart/templates/rbac.yaml
@@ -80,6 +80,13 @@ rules:
   - get
   - update
 {{- end }}
+- apiGroups:
+  - apiextensions.k8s.io
+  resources:
+  - customresourcedefinitions
+  verbs:
+  - list
+  - watch
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
diff --git a/deploy/manifests/nginx-gateway.yaml b/deploy/manifests/nginx-gateway.yaml
index 48e1f6accf..80fc304c05 100644
--- a/deploy/manifests/nginx-gateway.yaml
+++ b/deploy/manifests/nginx-gateway.yaml
@@ -89,6 +89,13 @@ rules:
   - create
   - get
   - update
+- apiGroups:
+  - apiextensions.k8s.io
+  resources:
+  - customresourcedefinitions
+  verbs:
+  - list
+  - watch
 ---
 # Source: nginx-gateway-fabric/templates/rbac.yaml
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/docs/developer/release-process.md b/docs/developer/release-process.md
index 966512eb8e..92c1df5f28 100644
--- a/docs/developer/release-process.md
+++ b/docs/developer/release-process.md
@@ -30,8 +30,10 @@ To create a new release, follow these steps:
 3. Test the main branch for release-readiness. For that, use the `edge` containers, which are built from the main
    branch, and the [example applications](/examples).
 4. If a problem is found, prepare a fix PR, merge it into the main branch and return to the previous step.
-5. Create a release branch with a name that follows the `release-X.Y` format.
-6. Prepare and merge a PR into the release branch to update the repo files for the release:
+5. If the supported Gateway API minor version has changed since the last release, test NGINX Gateway Fabric with the previous version of the Gateway API CRDs.
+6. If a compatibility issue is found, add a note to the release notes explaining that the previous version is not supported.
+7. Create a release branch following the `release-X.Y` naming convention.
+8. Prepare and merge a PR into the release branch to update the repo files for the release:
     1. Update the Helm [Chart.yaml](/deploy/helm-chart/Chart.yaml): the `appVersion` to `X.Y.Z`, the icon and source
        URLs to point at `vX.Y.Z`, and bump the `version`.
     2. Adjust the `VERSION` variable in the [Makefile](/Makefile) and the `TAG` in the
@@ -52,17 +54,17 @@ To create a new release, follow these steps:
         draft of the full changelog. This draft can be found under
         the [GitHub releases](https://github.com/nginxinc/nginx-gateway-fabric/releases) after the release branch is
         created. Use the previous changelog entries for formatting and content guidance.
-7. Create and push the release tag in the format `vX.Y.Z`. As a result, the CI/CD pipeline will:
+9. Create and push the release tag in the format `vX.Y.Z`. As a result, the CI/CD pipeline will:
     - Build NGF container images with the release tag `X.Y.Z` and push it to the registry.
     - Package and publish the Helm chart to the registry.
     - Create a GitHub release with an autogenerated changelog and attached release artifacts.
-8. Prepare and merge a PR into the main branch to update the [README](/README.md) to include the information about
-   the latest release and also the [changelog](/CHANGELOG.md). Also update any installation instructions to ensure
-   that the supported Gateway API and NGF versions are correct. Specifically, helm README and `site/content/includes/installation/install-gateway-api-resources.md`.
-9. Close the issue created in Step 1.
-10. Ensure that the [associated milestone](https://github.com/nginxinc/nginx-gateway-fabric/milestones) is closed.
-11. Verify that published artifacts in the release can be installed properly.
-12. Submit the `conformance-profile.yaml` artifact from the release to the [Gateway API repo](https://github.com/kubernetes-sigs/gateway-api/tree/main/conformance/reports).
+10. Prepare and merge a PR into the main branch to update the [README](/README.md) to include the information about
+    the latest release and also the [changelog](/CHANGELOG.md). Also update any installation instructions to ensure
+    that the supported Gateway API and NGF versions are correct. Specifically, helm README and `site/content/includes/installation/install-gateway-api-resources.md`.
+11. Close the issue created in Step 1.
+12. Ensure that the [associated milestone](https://github.com/nginxinc/nginx-gateway-fabric/milestones) is closed.
+13. Verify that published artifacts in the release can be installed properly.
+14. Submit the `conformance-profile.yaml` artifact from the release to the [Gateway API repo](https://github.com/kubernetes-sigs/gateway-api/tree/main/conformance/reports).
     - Create a fork of the repository
     - Name the file `nginxinc-nginx-gateway-fabric.yaml` and set `gatewayAPIVersion` in the file to the
     supported version by NGF. Also update the site source if necessary (see following example).
diff --git a/internal/framework/conditions/conditions.go b/internal/framework/conditions/conditions.go
index 2dfc21fdf7..e13f21d2c4 100644
--- a/internal/framework/conditions/conditions.go
+++ b/internal/framework/conditions/conditions.go
@@ -1,6 +1,8 @@
 package conditions
 
 import (
+	"fmt"
+
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	v1 "sigs.k8s.io/gateway-api/apis/v1"
 )
@@ -24,7 +26,40 @@ type Condition struct {
 	Message string
 }
 
-// NewDefaultGatewayClassConditions returns the default Conditions that must be present in the status of a GatewayClass.
+// DeduplicateConditions removes duplicate conditions based on the condition type.
+// The last condition wins. The order of conditions is preserved.
+func DeduplicateConditions(conds []Condition) []Condition {
+	type elem struct {
+		cond       Condition
+		reverseIdx int
+	}
+
+	uniqueElems := make(map[string]elem)
+
+	idx := 0
+	for i := len(conds) - 1; i >= 0; i-- {
+		if _, exist := uniqueElems[conds[i].Type]; exist {
+			continue
+		}
+
+		uniqueElems[conds[i].Type] = elem{
+			cond:       conds[i],
+			reverseIdx: idx,
+		}
+		idx++
+	}
+
+	result := make([]Condition, len(uniqueElems))
+
+	for _, el := range uniqueElems {
+		result[len(result)-el.reverseIdx-1] = el.cond
+	}
+
+	return result
+}
+
+// NewDefaultGatewayClassConditions returns Conditions that indicate that the GatewayClass is accepted and that the
+// Gateway API CRD versions are supported.
 func NewDefaultGatewayClassConditions() []Condition {
 	return []Condition{
 		{
@@ -33,6 +68,54 @@ func NewDefaultGatewayClassConditions() []Condition {
 			Reason:  string(v1.GatewayClassReasonAccepted),
 			Message: "GatewayClass is accepted",
 		},
+		{
+			Type:    string(v1.GatewayClassConditionStatusSupportedVersion),
+			Status:  metav1.ConditionTrue,
+			Reason:  string(v1.GatewayClassReasonSupportedVersion),
+			Message: "Gateway API CRD versions are supported",
+		},
+	}
+}
+
+// NewGatewayClassSupportedVersionBestEffort returns a Condition that indicates that the GatewayClass is accepted,
+// but the Gateway API CRD versions are not supported. This means NGF will attempt to generate configuration,
+// but it does not guarantee support.
+func NewGatewayClassSupportedVersionBestEffort(recommendedVersion string) []Condition {
+	return []Condition{
+		{
+			Type:   string(v1.GatewayClassConditionStatusSupportedVersion),
+			Status: metav1.ConditionFalse,
+			Reason: string(v1.GatewayClassReasonUnsupportedVersion),
+			Message: fmt.Sprintf(
+				"Gateway API CRD versions are not recommended. Recommended version is %s",
+				recommendedVersion,
+			),
+		},
+	}
+}
+
+// NewGatewayClassUnsupportedVersion returns Conditions that indicate that the GatewayClass is not accepted because
+// the Gateway API CRD versions are not supported. NGF will not generate configuration in this case.
+func NewGatewayClassUnsupportedVersion(recommendedVersion string) []Condition {
+	return []Condition{
+		{
+			Type:   string(v1.GatewayClassConditionStatusAccepted),
+			Status: metav1.ConditionFalse,
+			Reason: string(v1.GatewayClassReasonUnsupportedVersion),
+			Message: fmt.Sprintf(
+				"Gateway API CRD versions are not supported. Please install version %s",
+				recommendedVersion,
+			),
+		},
+		{
+			Type:   string(v1.GatewayClassConditionStatusSupportedVersion),
+			Status: metav1.ConditionFalse,
+			Reason: string(v1.GatewayClassReasonUnsupportedVersion),
+			Message: fmt.Sprintf(
+				"Gateway API CRD versions are not supported. Please install version %s",
+				recommendedVersion,
+			),
+		},
 	}
 }
 
diff --git a/internal/mode/static/state/conditions/conditions_test.go b/internal/framework/conditions/conditions_test.go
similarity index 86%
rename from internal/mode/static/state/conditions/conditions_test.go
rename to internal/framework/conditions/conditions_test.go
index 8729994c29..de7e31cb1e 100644
--- a/internal/mode/static/state/conditions/conditions_test.go
+++ b/internal/framework/conditions/conditions_test.go
@@ -5,14 +5,12 @@ import (
 
 	. "github.com/onsi/gomega"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-
-	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/conditions"
 )
 
 func TestDeduplicateConditions(t *testing.T) {
 	g := NewWithT(t)
 
-	conds := []conditions.Condition{
+	conds := []Condition{
 		{
 			Type:    "Type1",
 			Status:  metav1.ConditionTrue,
@@ -40,7 +38,7 @@ func TestDeduplicateConditions(t *testing.T) {
 		},
 	}
 
-	expected := []conditions.Condition{
+	expected := []Condition{
 		{
 			Type:    "Type1",
 			Status:  metav1.ConditionFalse,
diff --git a/internal/framework/controller/index/endpointslice_test.go b/internal/framework/controller/index/endpointslice_test.go
index 749f53828d..23e09dbf41 100644
--- a/internal/framework/controller/index/endpointslice_test.go
+++ b/internal/framework/controller/index/endpointslice_test.go
@@ -51,7 +51,7 @@ func TestServiceNameIndexFunc(t *testing.T) {
 func TestServiceNameIndexFuncPanics(t *testing.T) {
 	defer func() {
 		g := NewWithT(t)
-		g.Expect(recover()).ShouldNot(BeNil())
+		g.Expect(recover()).ToNot(BeNil())
 	}()
 
 	ServiceNameIndexFunc(&v1.Namespace{})
diff --git a/internal/framework/controller/predicate/annotation.go b/internal/framework/controller/predicate/annotation.go
new file mode 100644
index 0000000000..fdf1fd696f
--- /dev/null
+++ b/internal/framework/controller/predicate/annotation.go
@@ -0,0 +1,39 @@
+package predicate
+
+import (
+	"sigs.k8s.io/controller-runtime/pkg/event"
+	"sigs.k8s.io/controller-runtime/pkg/predicate"
+)
+
+// AnnotationPredicate implements a predicate function based on the Annotation.
+//
+// This predicate will skip the following events:
+// 1. Create events that do not contain the Annotation.
+// 2. Update events where the Annotation value has not changed.
+type AnnotationPredicate struct {
+	predicate.Funcs
+	Annotation string
+}
+
+// Create filters CreateEvents based on the Annotation.
+func (cp AnnotationPredicate) Create(e event.CreateEvent) bool {
+	if e.Object == nil {
+		return false
+	}
+
+	_, ok := e.Object.GetAnnotations()[cp.Annotation]
+	return ok
+}
+
+// Update filters UpdateEvents based on the Annotation.
+func (cp AnnotationPredicate) Update(e event.UpdateEvent) bool {
+	if e.ObjectOld == nil || e.ObjectNew == nil {
+		// this case should not happen
+		return false
+	}
+
+	oldAnnotationVal := e.ObjectOld.GetAnnotations()[cp.Annotation]
+	newAnnotationVal := e.ObjectNew.GetAnnotations()[cp.Annotation]
+
+	return oldAnnotationVal != newAnnotationVal
+}
diff --git a/internal/framework/controller/predicate/annotation_test.go b/internal/framework/controller/predicate/annotation_test.go
new file mode 100644
index 0000000000..6fe18b3a28
--- /dev/null
+++ b/internal/framework/controller/predicate/annotation_test.go
@@ -0,0 +1,220 @@
+package predicate
+
+import (
+	"testing"
+
+	. "github.com/onsi/gomega"
+	apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/event"
+)
+
+func TestAnnotationPredicate_Create(t *testing.T) {
+	annotation := "test"
+
+	tests := []struct {
+		event     event.CreateEvent
+		name      string
+		expUpdate bool
+	}{
+		{
+			name: "object has annotation",
+			event: event.CreateEvent{
+				Object: &apiext.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Annotations: map[string]string{
+							annotation: "one",
+						},
+					},
+				},
+			},
+			expUpdate: true,
+		},
+		{
+			name: "object does not have annotation",
+			event: event.CreateEvent{
+				Object: &apiext.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Annotations: map[string]string{
+							"diff": "one",
+						},
+					},
+				},
+			},
+			expUpdate: false,
+		},
+		{
+			name:      "object does not have any annotations",
+			event:     event.CreateEvent{Object: &apiext.CustomResourceDefinition{}},
+			expUpdate: false,
+		},
+		{
+			name:      "object is nil",
+			event:     event.CreateEvent{Object: nil},
+			expUpdate: false,
+		},
+	}
+
+	p := AnnotationPredicate{Annotation: annotation}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			g := NewWithT(t)
+			update := p.Create(test.event)
+			g.Expect(update).To(Equal(test.expUpdate))
+		})
+	}
+}
+
+func TestAnnotationPredicate_Update(t *testing.T) {
+	annotation := "test"
+
+	tests := []struct {
+		event     event.UpdateEvent
+		name      string
+		expUpdate bool
+	}{
+		{
+			name: "annotation changed",
+			event: event.UpdateEvent{
+				ObjectOld: &apiext.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Annotations: map[string]string{
+							annotation: "one",
+						},
+					},
+				},
+				ObjectNew: &apiext.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Annotations: map[string]string{
+							annotation: "two",
+						},
+					},
+				},
+			},
+			expUpdate: true,
+		},
+		{
+			name: "annotation deleted",
+			event: event.UpdateEvent{
+				ObjectOld: &apiext.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Annotations: map[string]string{
+							annotation: "one",
+						},
+					},
+				},
+				ObjectNew: &apiext.CustomResourceDefinition{},
+			},
+			expUpdate: true,
+		},
+		{
+			name: "annotation added",
+			event: event.UpdateEvent{
+				ObjectOld: &apiext.CustomResourceDefinition{},
+				ObjectNew: &apiext.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Annotations: map[string]string{
+							annotation: "one",
+						},
+					},
+				},
+			},
+			expUpdate: true,
+		},
+		{
+			name: "annotation has not changed",
+			event: event.UpdateEvent{
+				ObjectOld: &apiext.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Annotations: map[string]string{
+							annotation: "one",
+						},
+					},
+				},
+				ObjectNew: &apiext.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Annotations: map[string]string{
+							annotation: "one",
+						},
+					},
+				},
+			},
+			expUpdate: false,
+		},
+		{
+			name: "different annotation changed",
+			event: event.UpdateEvent{
+				ObjectOld: &apiext.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Annotations: map[string]string{
+							"diff": "one",
+						},
+					},
+				},
+				ObjectNew: &apiext.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Annotations: map[string]string{
+							"diff": "two",
+						},
+					},
+				},
+			},
+			expUpdate: false,
+		},
+		{
+			name: "no annotations",
+			event: event.UpdateEvent{
+				ObjectOld: &apiext.CustomResourceDefinition{},
+				ObjectNew: &apiext.CustomResourceDefinition{},
+			},
+			expUpdate: false,
+		},
+		{
+			name: "old object is nil",
+			event: event.UpdateEvent{
+				ObjectOld: nil,
+				ObjectNew: &apiext.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Annotations: map[string]string{
+							annotation: "one",
+						},
+					},
+				},
+			},
+			expUpdate: false,
+		},
+		{
+			name: "new object is nil",
+			event: event.UpdateEvent{
+				ObjectOld: &apiext.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Annotations: map[string]string{
+							annotation: "one",
+						},
+					},
+				},
+				ObjectNew: nil,
+			},
+			expUpdate: false,
+		},
+		{
+			name: "both objects are nil",
+			event: event.UpdateEvent{
+				ObjectOld: nil,
+				ObjectNew: nil,
+			},
+			expUpdate: false,
+		},
+	}
+
+	p := AnnotationPredicate{Annotation: annotation}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			g := NewWithT(t)
+			update := p.Update(test.event)
+			g.Expect(update).To(Equal(test.expUpdate))
+		})
+	}
+}
diff --git a/internal/framework/controller/reconciler.go b/internal/framework/controller/reconciler.go
index 43a5ac2002..5d90891da5 100644
--- a/internal/framework/controller/reconciler.go
+++ b/internal/framework/controller/reconciler.go
@@ -6,6 +6,7 @@ import (
 	"reflect"
 
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/log"
@@ -28,6 +29,8 @@ type ReconcilerConfig struct {
 	EventCh chan<- interface{}
 	// NamespacedNameFilter filters resources the controller will process. Can be nil.
 	NamespacedNameFilter NamespacedNameFilterFunc
+	// OnlyMetadata indicates that this controller for this resource is only caching metadata for the resource.
+	OnlyMetadata bool
 }
 
 // Reconciler reconciles Kubernetes resources of a specific type.
@@ -49,12 +52,18 @@ func NewReconciler(cfg ReconcilerConfig) *Reconciler {
 	}
 }
 
-func newObject(objectType client.Object) client.Object {
+func (r *Reconciler) newObject(objectType client.Object) client.Object {
+	if r.cfg.OnlyMetadata {
+		partialObj := &metav1.PartialObjectMetadata{}
+		partialObj.SetGroupVersionKind(objectType.GetObjectKind().GroupVersionKind())
+
+		return partialObj
+	}
+
 	// without Elem(), t will be a pointer to the type. For example, *v1.Gateway, not v1.Gateway
 	t := reflect.TypeOf(objectType).Elem()
 
 	// We could've used objectType.DeepCopyObject() here, but it's a bit slower confirmed by benchmarks.
-
 	return reflect.New(t).Interface().(client.Object)
 }
 
@@ -73,7 +82,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 		}
 	}
 
-	obj := newObject(r.cfg.ObjectType)
+	obj := r.newObject(r.cfg.ObjectType)
+
 	if err := r.cfg.Getter.Get(ctx, req.NamespacedName, obj); err != nil {
 		if !apierrors.IsNotFound(err) {
 			logger.Error(err, "Failed to get the resource")
diff --git a/internal/framework/controller/register.go b/internal/framework/controller/register.go
index b48fd2258a..fbb5a7e712 100644
--- a/internal/framework/controller/register.go
+++ b/internal/framework/controller/register.go
@@ -6,6 +6,7 @@ import (
 	"time"
 
 	ctlr "sigs.k8s.io/controller-runtime"
+	ctlrBuilder "sigs.k8s.io/controller-runtime/pkg/builder"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/manager"
 	"sigs.k8s.io/controller-runtime/pkg/predicate"
@@ -23,6 +24,7 @@ type config struct {
 	k8sPredicate         predicate.Predicate
 	fieldIndices         index.FieldIndices
 	newReconciler        NewReconcilerFunc
+	onlyMetadata         bool
 }
 
 // NewReconcilerFunc defines a function that creates a new Reconciler. Used for unit-testing.
@@ -59,6 +61,16 @@ func WithNewReconciler(newReconciler NewReconcilerFunc) Option {
 	}
 }
 
+// WithOnlyMetadata tells the controller to only cache metadata, and to watch the API server in metadata-only form.
+// If using this option, you must set the GroupVersionKind on the ObjectType you pass into the Register function.
+// If watching a resource with OnlyMetadata, for example the v1.Pod, you must not Get and List using the v1.Pod type.
+// Instead, you must use the special metav1.PartialObjectMetadata type.
+func WithOnlyMetadata() Option {
+	return func(cfg *config) {
+		cfg.onlyMetadata = true
+	}
+}
+
 func defaultConfig() config {
 	return config{
 		newReconciler: NewReconciler,
@@ -93,7 +105,15 @@ func Register(
 		}
 	}
 
-	builder := ctlr.NewControllerManagedBy(mgr).For(objectType)
+	var forOpts []ctlrBuilder.ForOption
+	if cfg.onlyMetadata {
+		if objectType.GetObjectKind().GroupVersionKind().Empty() {
+			panic("the object must have its GVK set")
+		}
+		forOpts = append(forOpts, ctlrBuilder.OnlyMetadata)
+	}
+
+	builder := ctlr.NewControllerManagedBy(mgr).For(objectType, forOpts...)
 
 	if cfg.k8sPredicate != nil {
 		builder = builder.WithEventFilter(cfg.k8sPredicate)
@@ -104,6 +124,7 @@ func Register(
 		ObjectType:           objectType,
 		EventCh:              eventCh,
 		NamespacedNameFilter: cfg.namespacedNameFilter,
+		OnlyMetadata:         cfg.onlyMetadata,
 	}
 
 	if err := builder.Complete(cfg.newReconciler(recCfg)); err != nil {
diff --git a/internal/framework/controller/register_test.go b/internal/framework/controller/register_test.go
index 3a591b9e6d..ac4e4c61c9 100644
--- a/internal/framework/controller/register_test.go
+++ b/internal/framework/controller/register_test.go
@@ -10,8 +10,10 @@ import (
 	"github.com/onsi/gomega/gcustom"
 	gtypes "github.com/onsi/gomega/types"
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
 	"sigs.k8s.io/controller-runtime/pkg/log/zap"
 	v1 "sigs.k8s.io/gateway-api/apis/v1"
@@ -50,14 +52,24 @@ func TestRegister(t *testing.T) {
 
 	testError := errors.New("test error")
 
+	objectTypeWithGVK := &v1.HTTPRoute{}
+	objectTypeWithGVK.SetGroupVersionKind(
+		schema.GroupVersionKind{Group: v1.GroupName, Version: "v1", Kind: "HTTPRoute"},
+	)
+
+	objectTypeNoGVK := &v1.HTTPRoute{}
+
 	tests := []struct {
 		fakes                   fakes
+		objectType              client.Object
 		expectedErr             error
 		msg                     string
 		expectedMgrAddCallCount int
+		expectPanic             bool
 	}{
 		{
 			fakes:                   getDefaultFakes(),
+			objectType:              objectTypeWithGVK,
 			expectedErr:             nil,
 			expectedMgrAddCallCount: 1,
 			msg:                     "normal case",
@@ -67,6 +79,7 @@ func TestRegister(t *testing.T) {
 				f.indexer.IndexFieldReturns(testError)
 				return f
 			}(getDefaultFakes()),
+			objectType:              objectTypeWithGVK,
 			expectedErr:             testError,
 			expectedMgrAddCallCount: 0,
 			msg:                     "preparing index fails",
@@ -76,13 +89,20 @@ func TestRegister(t *testing.T) {
 				f.mgr.AddReturns(testError)
 				return f
 			}(getDefaultFakes()),
+			objectType:              objectTypeWithGVK,
 			expectedErr:             testError,
 			expectedMgrAddCallCount: 1,
 			msg:                     "building controller fails",
 		},
+		{
+			fakes:                   getDefaultFakes(),
+			objectType:              objectTypeNoGVK,
+			expectPanic:             true,
+			expectedMgrAddCallCount: 0,
+			msg:                     "adding OnlyMetadata option panics",
+		},
 	}
 
-	objectType := &v1.HTTPRoute{}
 	nsNameFilter := func(nsname types.NamespacedName) (bool, string) {
 		return true, ""
 	}
@@ -104,28 +124,36 @@ func TestRegister(t *testing.T) {
 
 			newReconciler := func(c controller.ReconcilerConfig) *controller.Reconciler {
 				g.Expect(c.Getter).To(BeIdenticalTo(test.fakes.mgr.GetClient()))
-				g.Expect(c.ObjectType).To(BeIdenticalTo(objectType))
+				g.Expect(c.ObjectType).To(BeIdenticalTo(test.objectType))
 				g.Expect(c.EventCh).To(BeIdenticalTo(eventCh))
 				g.Expect(c.NamespacedNameFilter).Should(beSameFunctionPointer(nsNameFilter))
 
 				return controller.NewReconciler(c)
 			}
 
-			err := controller.Register(
-				context.Background(),
-				objectType,
-				test.fakes.mgr,
-				eventCh,
-				controller.WithNamespacedNameFilter(nsNameFilter),
-				controller.WithK8sPredicate(predicate.ServicePortsChangedPredicate{}),
-				controller.WithFieldIndices(fieldIndexes),
-				controller.WithNewReconciler(newReconciler),
-			)
-
-			if test.expectedErr == nil {
-				g.Expect(err).To(BeNil())
+			register := func() error {
+				return controller.Register(
+					context.Background(),
+					test.objectType,
+					test.fakes.mgr,
+					eventCh,
+					controller.WithNamespacedNameFilter(nsNameFilter),
+					controller.WithK8sPredicate(predicate.ServicePortsChangedPredicate{}),
+					controller.WithFieldIndices(fieldIndexes),
+					controller.WithNewReconciler(newReconciler),
+					controller.WithOnlyMetadata(),
+				)
+			}
+
+			if test.expectPanic {
+				g.Expect(func() { _ = register() }).To(Panic())
 			} else {
-				g.Expect(err).To(MatchError(test.expectedErr))
+				err := register()
+				if test.expectedErr == nil {
+					g.Expect(err).To(BeNil())
+				} else {
+					g.Expect(err).To(MatchError(test.expectedErr))
+				}
 			}
 
 			indexCallCount := test.fakes.indexer.IndexFieldCallCount()
@@ -134,7 +162,7 @@ func TestRegister(t *testing.T) {
 
 			_, objType, field, indexFunc := test.fakes.indexer.IndexFieldArgsForCall(0)
 
-			g.Expect(objType).To(BeIdenticalTo(objectType))
+			g.Expect(objType).To(BeIdenticalTo(test.objectType))
 			g.Expect(field).To(BeIdenticalTo(index.KubernetesServiceNameIndexField))
 
 			expectedIndexFunc := fieldIndexes[index.KubernetesServiceNameIndexField]
diff --git a/internal/framework/gatewayclass/validate.go b/internal/framework/gatewayclass/validate.go
new file mode 100644
index 0000000000..266e6ca5bc
--- /dev/null
+++ b/internal/framework/gatewayclass/validate.go
@@ -0,0 +1,83 @@
+package gatewayclass
+
+import (
+	"strings"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/conditions"
+)
+
+const (
+	// BundleVersionAnnotation is the annotation on Gateway API CRDs that contains the installed version.
+	BundleVersionAnnotation = "gateway.networking.k8s.io/bundle-version"
+	// SupportedVersion is the supported version of the Gateway API CRDs.
+	SupportedVersion = "v1.0.0"
+)
+
+var gatewayCRDs = map[string]apiVersion{
+	"gatewayclasses.gateway.networking.k8s.io":  {},
+	"gateways.gateway.networking.k8s.io":        {},
+	"httproutes.gateway.networking.k8s.io":      {},
+	"referencegrants.gateway.networking.k8s.io": {},
+}
+
+type apiVersion struct {
+	major string
+	minor string
+}
+
+func ValidateCRDVersions(
+	crdMetadata map[types.NamespacedName]*metav1.PartialObjectMetadata,
+) (conds []conditions.Condition, valid bool) {
+	installedAPIVersions := getBundleVersions(crdMetadata)
+	supportedAPIVersion := parseVersionString(SupportedVersion)
+
+	var unsupported, bestEffort bool
+
+	for _, version := range installedAPIVersions {
+		if version.major != supportedAPIVersion.major {
+			unsupported = true
+		} else if version.minor != supportedAPIVersion.minor {
+			bestEffort = true
+		}
+	}
+
+	if unsupported {
+		return conditions.NewGatewayClassUnsupportedVersion(SupportedVersion), false
+	}
+
+	if bestEffort {
+		return conditions.NewGatewayClassSupportedVersionBestEffort(SupportedVersion), true
+	}
+
+	return nil, true
+}
+
+func parseVersionString(version string) apiVersion {
+	versionBits := strings.Split(version, ".")
+	if len(versionBits) != 3 {
+		return apiVersion{}
+	}
+
+	major, _ := strings.CutPrefix(versionBits[0], "v")
+
+	return apiVersion{
+		major: major,
+		minor: versionBits[1],
+	}
+}
+
+func getBundleVersions(crdMetadata map[types.NamespacedName]*metav1.PartialObjectMetadata) []apiVersion {
+	versions := make([]apiVersion, 0, len(gatewayCRDs))
+
+	for nsname, md := range crdMetadata {
+		if _, ok := gatewayCRDs[nsname.Name]; ok {
+			bundleVersion := md.Annotations[BundleVersionAnnotation]
+			versions = append(versions, parseVersionString(bundleVersion))
+		}
+	}
+
+	return versions
+}
diff --git a/internal/framework/gatewayclass/validate_test.go b/internal/framework/gatewayclass/validate_test.go
new file mode 100644
index 0000000000..8359365477
--- /dev/null
+++ b/internal/framework/gatewayclass/validate_test.go
@@ -0,0 +1,124 @@
+package gatewayclass_test
+
+import (
+	"strings"
+	"testing"
+
+	. "github.com/onsi/gomega"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/conditions"
+	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/gatewayclass"
+)
+
+func TestValidateCRDVersions(t *testing.T) {
+	createCRDMetadata := func(version string) *metav1.PartialObjectMetadata {
+		return &metav1.PartialObjectMetadata{
+			ObjectMeta: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					gatewayclass.BundleVersionAnnotation: version,
+				},
+			},
+		}
+	}
+
+	// Adding patch version to SupportedVersion to try and avoid having to update these tests with every release.
+	fields := strings.Split(gatewayclass.SupportedVersion, ".")
+	fields[2] = "99"
+
+	validVersionWithPatch := createCRDMetadata(strings.Join(fields, "."))
+	bestEffortVersion := createCRDMetadata("v1.99.99")
+	unsupportedVersion := createCRDMetadata("v99.0.0")
+
+	tests := []struct {
+		crds     map[types.NamespacedName]*metav1.PartialObjectMetadata
+		name     string
+		expConds []conditions.Condition
+		valid    bool
+	}{
+		{
+			name: "valid; all supported versions",
+			crds: map[types.NamespacedName]*metav1.PartialObjectMetadata{
+				{Name: "gatewayclasses.gateway.networking.k8s.io"}:  validVersionWithPatch,
+				{Name: "gateways.gateway.networking.k8s.io"}:        validVersionWithPatch,
+				{Name: "httproutes.gateway.networking.k8s.io"}:      validVersionWithPatch,
+				{Name: "referencegrants.gateway.networking.k8s.io"}: validVersionWithPatch,
+				{Name: "some.other.crd"}:                            unsupportedVersion, /* should ignore */
+			},
+			valid:    true,
+			expConds: nil,
+		},
+		{
+			name: "valid; only one Gateway API CRD exists but it's a supported version",
+			crds: map[types.NamespacedName]*metav1.PartialObjectMetadata{
+				{Name: "gatewayclasses.gateway.networking.k8s.io"}: validVersionWithPatch,
+				{Name: "some.other.crd"}:                           unsupportedVersion, /* should ignore */
+			},
+			valid:    true,
+			expConds: nil,
+		},
+		{
+			name: "valid; all best effort (supported major version)",
+			crds: map[types.NamespacedName]*metav1.PartialObjectMetadata{
+				{Name: "gatewayclasses.gateway.networking.k8s.io"}:  bestEffortVersion,
+				{Name: "gateways.gateway.networking.k8s.io"}:        bestEffortVersion,
+				{Name: "httproutes.gateway.networking.k8s.io"}:      bestEffortVersion,
+				{Name: "referencegrants.gateway.networking.k8s.io"}: bestEffortVersion,
+			},
+			valid:    true,
+			expConds: conditions.NewGatewayClassSupportedVersionBestEffort(gatewayclass.SupportedVersion),
+		},
+		{
+			name: "valid; mix of supported and best effort versions",
+			crds: map[types.NamespacedName]*metav1.PartialObjectMetadata{
+				{Name: "gatewayclasses.gateway.networking.k8s.io"}:  validVersionWithPatch,
+				{Name: "gateways.gateway.networking.k8s.io"}:        bestEffortVersion,
+				{Name: "httproutes.gateway.networking.k8s.io"}:      validVersionWithPatch,
+				{Name: "referencegrants.gateway.networking.k8s.io"}: validVersionWithPatch,
+			},
+			valid:    true,
+			expConds: conditions.NewGatewayClassSupportedVersionBestEffort(gatewayclass.SupportedVersion),
+		},
+		{
+			name: "invalid; all unsupported versions",
+			crds: map[types.NamespacedName]*metav1.PartialObjectMetadata{
+				{Name: "gatewayclasses.gateway.networking.k8s.io"}:  unsupportedVersion,
+				{Name: "gateways.gateway.networking.k8s.io"}:        unsupportedVersion,
+				{Name: "httproutes.gateway.networking.k8s.io"}:      unsupportedVersion,
+				{Name: "referencegrants.gateway.networking.k8s.io"}: unsupportedVersion,
+			},
+			valid:    false,
+			expConds: conditions.NewGatewayClassUnsupportedVersion(gatewayclass.SupportedVersion),
+		},
+		{
+			name: "invalid; mix unsupported and best effort versions",
+			crds: map[types.NamespacedName]*metav1.PartialObjectMetadata{
+				{Name: "gatewayclasses.gateway.networking.k8s.io"}:  unsupportedVersion,
+				{Name: "gateways.gateway.networking.k8s.io"}:        bestEffortVersion,
+				{Name: "httproutes.gateway.networking.k8s.io"}:      unsupportedVersion,
+				{Name: "referencegrants.gateway.networking.k8s.io"}: bestEffortVersion,
+			},
+			valid:    false,
+			expConds: conditions.NewGatewayClassUnsupportedVersion(gatewayclass.SupportedVersion),
+		},
+		{
+			name: "invalid; bad version string",
+			crds: map[types.NamespacedName]*metav1.PartialObjectMetadata{
+				{Name: "gatewayclasses.gateway.networking.k8s.io"}: createCRDMetadata("v"),
+			},
+			valid:    false,
+			expConds: conditions.NewGatewayClassUnsupportedVersion(gatewayclass.SupportedVersion),
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			g := NewWithT(t)
+
+			conds, valid := gatewayclass.ValidateCRDVersions(test.crds)
+			g.Expect(valid).To(Equal(test.valid))
+			g.Expect(conds).To(Equal(test.expConds))
+		})
+	}
+}
diff --git a/internal/mode/provisioner/handler.go b/internal/mode/provisioner/handler.go
index 0495e176a7..853e814f1b 100644
--- a/internal/mode/provisioner/handler.go
+++ b/internal/mode/provisioner/handler.go
@@ -11,6 +11,7 @@ import (
 
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/conditions"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/events"
+	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/gatewayclass"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/status"
 )
 
@@ -56,20 +57,29 @@ func (h *eventHandler) setGatewayClassStatuses(ctx context.Context) {
 	}
 
 	var gcExists bool
+
 	for nsname, gc := range h.store.gatewayClasses {
-		var conds []conditions.Condition
+		// The order of conditions matters. Default conditions are added first so that any additional conditions will
+		// override them, which is ensured by DeduplicateConditions.
+		conds := conditions.NewDefaultGatewayClassConditions()
+
 		if gc.Name == h.gcName {
 			gcExists = true
-			conds = conditions.NewDefaultGatewayClassConditions()
 		} else {
-			conds = []conditions.Condition{conditions.NewGatewayClassConflict()}
+			conds = append(conds, conditions.NewGatewayClassConflict())
 		}
 
+		// We ignore the boolean return value here because the provisioner only sets status,
+		// it does not generate config.
+		supportedVersionConds, _ := gatewayclass.ValidateCRDVersions(h.store.crdMetadata)
+		conds = append(conds, supportedVersionConds...)
+
 		statuses.GatewayClassStatuses[nsname] = status.GatewayClassStatus{
-			Conditions:         conds,
+			Conditions:         conditions.DeduplicateConditions(conds),
 			ObservedGeneration: gc.Generation,
 		}
 	}
+
 	if !gcExists {
 		panic(fmt.Errorf("GatewayClass %s must exist", h.gcName))
 	}
diff --git a/internal/mode/provisioner/handler_test.go b/internal/mode/provisioner/handler_test.go
index ada3cce1db..0faa894a9a 100644
--- a/internal/mode/provisioner/handler_test.go
+++ b/internal/mode/provisioner/handler_test.go
@@ -6,6 +6,7 @@ import (
 
 	. "github.com/onsi/ginkgo/v2"
 	v1 "k8s.io/api/apps/v1"
+	apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
@@ -19,6 +20,7 @@ import (
 	embeddedfiles "github.com/nginxinc/nginx-gateway-fabric"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/conditions"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/events"
+	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/gatewayclass"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/helpers"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/status"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/status/statusfakes"
@@ -34,6 +36,8 @@ var _ = Describe("handler", func() {
 
 		statusUpdater status.Updater
 		k8sclient     client.Client
+		crd           *metav1.PartialObjectMetadata
+		gc            *gatewayv1.GatewayClass
 	)
 
 	BeforeEach(OncePerOrdered, func() {
@@ -41,6 +45,7 @@ var _ = Describe("handler", func() {
 
 		Expect(gatewayv1.AddToScheme(scheme)).Should(Succeed())
 		Expect(v1.AddToScheme(scheme)).Should(Succeed())
+		Expect(apiext.AddToScheme(scheme)).Should(Succeed())
 
 		k8sclient = fake.NewClientBuilder().
 			WithScheme(scheme).
@@ -62,6 +67,29 @@ var _ = Describe("handler", func() {
 			GatewayClassName:         gcName,
 			UpdateGatewayClassStatus: true,
 		})
+
+		// Add GatewayClass CRD to the cluster
+		crd = &metav1.PartialObjectMetadata{
+			TypeMeta: metav1.TypeMeta{
+				Kind:       "CustomResourceDefinition",
+				APIVersion: "apiextensions.k8s.io/v1",
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "gatewayclasses.gateway.networking.k8s.io",
+				Annotations: map[string]string{
+					gatewayclass.BundleVersionAnnotation: gatewayclass.SupportedVersion,
+				},
+			},
+		}
+
+		err := k8sclient.Create(context.Background(), crd)
+		Expect(err).ToNot(HaveOccurred())
+
+		gc = &gatewayv1.GatewayClass{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: gcName,
+			},
+		}
 	})
 
 	createGateway := func(gwNsName types.NamespacedName) *gatewayv1.Gateway {
@@ -79,21 +107,18 @@ var _ = Describe("handler", func() {
 	itShouldUpsertGatewayClass := func() {
 		// Add GatewayClass to the cluster
 
-		gc := &gatewayv1.GatewayClass{
-			ObjectMeta: metav1.ObjectMeta{
-				Name: gcName,
-			},
-		}
-
 		err := k8sclient.Create(context.Background(), gc)
-		Expect(err).ShouldNot(HaveOccurred())
+		Expect(err).ToNot(HaveOccurred())
 
-		// UpsertGatewayClass
+		// UpsertGatewayClass and CRD
 
 		batch := []interface{}{
 			&events.UpsertEvent{
 				Resource: gc,
 			},
+			&events.UpsertEvent{
+				Resource: crd,
+			},
 		}
 		handler.HandleEventBatch(context.Background(), zap.New(), batch)
 
@@ -102,7 +127,7 @@ var _ = Describe("handler", func() {
 		clusterGc := &gatewayv1.GatewayClass{}
 		err = k8sclient.Get(context.Background(), client.ObjectKeyFromObject(gc), clusterGc)
 
-		Expect(err).ShouldNot(HaveOccurred())
+		Expect(err).ToNot(HaveOccurred())
 
 		expectedConditions := []metav1.Condition{
 			{
@@ -113,6 +138,14 @@ var _ = Describe("handler", func() {
 				Reason:             "Accepted",
 				Message:            "GatewayClass is accepted",
 			},
+			{
+				Type:               string(gatewayv1.GatewayClassReasonSupportedVersion),
+				Status:             metav1.ConditionTrue,
+				ObservedGeneration: 0,
+				LastTransitionTime: fakeClockTime,
+				Reason:             "SupportedVersion",
+				Message:            "Gateway API CRD versions are supported",
+			},
 		}
 
 		Expect(clusterGc.Status.Conditions).To(Equal(expectedConditions))
@@ -135,7 +168,7 @@ var _ = Describe("handler", func() {
 		dep := &v1.Deployment{}
 		err := k8sclient.Get(context.Background(), depNsName, dep)
 
-		Expect(err).ShouldNot(HaveOccurred())
+		Expect(err).ToNot(HaveOccurred())
 
 		Expect(dep.ObjectMeta.Namespace).To(Equal("nginx-gateway"))
 		Expect(dep.ObjectMeta.Name).To(Equal(depNsName.Name))
@@ -147,6 +180,73 @@ var _ = Describe("handler", func() {
 		Expect(dep.Spec.Template.Spec.Containers[0].Args).To(ContainElement(expectedLockFlag))
 	}
 
+	itShouldUpsertCRD := func(version string, accepted bool) {
+		updatedCRD := crd
+		updatedCRD.Annotations[gatewayclass.BundleVersionAnnotation] = version
+
+		err := k8sclient.Update(context.Background(), updatedCRD)
+		Expect(err).ToNot(HaveOccurred())
+
+		batch := []interface{}{
+			&events.UpsertEvent{
+				Resource: updatedCRD,
+			},
+		}
+
+		handler.HandleEventBatch(context.Background(), zap.New(), batch)
+
+		updatedGC := &gatewayv1.GatewayClass{}
+
+		err = k8sclient.Get(context.Background(), client.ObjectKeyFromObject(gc), updatedGC)
+		Expect(err).ToNot(HaveOccurred())
+
+		var expConds []metav1.Condition
+		if !accepted {
+			expConds = []metav1.Condition{
+				{
+					Type:               string(gatewayv1.GatewayClassConditionStatusAccepted),
+					Status:             metav1.ConditionFalse,
+					ObservedGeneration: 0,
+					LastTransitionTime: fakeClockTime,
+					Reason:             string(gatewayv1.GatewayClassReasonUnsupportedVersion),
+					Message: fmt.Sprintf("Gateway API CRD versions are not supported. "+
+						"Please install version %s", gatewayclass.SupportedVersion),
+				},
+				{
+					Type:               string(gatewayv1.GatewayClassReasonSupportedVersion),
+					Status:             metav1.ConditionFalse,
+					ObservedGeneration: 0,
+					LastTransitionTime: fakeClockTime,
+					Reason:             string(gatewayv1.GatewayClassReasonUnsupportedVersion),
+					Message: fmt.Sprintf("Gateway API CRD versions are not supported. "+
+						"Please install version %s", gatewayclass.SupportedVersion),
+				},
+			}
+		} else {
+			expConds = []metav1.Condition{
+				{
+					Type:               string(gatewayv1.GatewayClassConditionStatusAccepted),
+					Status:             metav1.ConditionTrue,
+					ObservedGeneration: 0,
+					LastTransitionTime: fakeClockTime,
+					Reason:             string(gatewayv1.GatewayClassReasonAccepted),
+					Message:            "GatewayClass is accepted",
+				},
+				{
+					Type:               string(gatewayv1.GatewayClassReasonSupportedVersion),
+					Status:             metav1.ConditionFalse,
+					ObservedGeneration: 0,
+					LastTransitionTime: fakeClockTime,
+					Reason:             string(gatewayv1.GatewayClassReasonUnsupportedVersion),
+					Message: fmt.Sprintf("Gateway API CRD versions are not recommended. "+
+						"Recommended version is %s", gatewayclass.SupportedVersion),
+				},
+			}
+		}
+
+		Expect(updatedGC.Status.Conditions).To(Equal(expConds))
+	}
+
 	itShouldPanicWhenUpsertingGateway := func(gwNsName types.NamespacedName) {
 		batch := []interface{}{
 			&events.UpsertEvent{
@@ -220,7 +320,7 @@ var _ = Describe("handler", func() {
 
 				err := k8sclient.List(context.Background(), deps)
 
-				Expect(err).ShouldNot(HaveOccurred())
+				Expect(err).ToNot(HaveOccurred())
 				Expect(deps.Items).To(HaveLen(1))
 				Expect(deps.Items[0].ObjectMeta.Name).To(Equal("nginx-gateway-2"))
 			})
@@ -241,7 +341,7 @@ var _ = Describe("handler", func() {
 
 				err := k8sclient.List(context.Background(), deps)
 
-				Expect(err).ShouldNot(HaveOccurred())
+				Expect(err).ToNot(HaveOccurred())
 				Expect(deps.Items).To(HaveLen(0))
 			})
 		})
@@ -269,14 +369,14 @@ var _ = Describe("handler", func() {
 				deps := &v1.DeploymentList{}
 				err := k8sclient.List(context.Background(), deps)
 
-				Expect(err).ShouldNot(HaveOccurred())
+				Expect(err).ToNot(HaveOccurred())
 				Expect(deps.Items).To(HaveLen(0))
 			})
 		})
 
 		When("upserting GatewayClass that is not set in command-line argument", func() {
 			It("should set the proper status if this controller is referenced", func() {
-				gc := &gatewayv1.GatewayClass{
+				newGC := &gatewayv1.GatewayClass{
 					ObjectMeta: metav1.ObjectMeta{
 						Name: "unknown-gc",
 					},
@@ -284,35 +384,58 @@ var _ = Describe("handler", func() {
 						ControllerName: "test.example.com",
 					},
 				}
-				err := k8sclient.Create(context.Background(), gc)
-				Expect(err).ShouldNot(HaveOccurred())
+
+				err := k8sclient.Create(context.Background(), newGC)
+				Expect(err).ToNot(HaveOccurred())
 
 				batch := []interface{}{
 					&events.UpsertEvent{
-						Resource: gc,
+						Resource: newGC,
+					},
+					&events.UpsertEvent{
+						Resource: crd,
 					},
 				}
 
 				handler.HandleEventBatch(context.Background(), zap.New(), batch)
 
 				unknownGC := &gatewayv1.GatewayClass{}
-				err = k8sclient.Get(context.Background(), client.ObjectKeyFromObject(gc), unknownGC)
-				Expect(err).ShouldNot(HaveOccurred())
+				err = k8sclient.Get(context.Background(), client.ObjectKeyFromObject(newGC), unknownGC)
+				Expect(err).ToNot(HaveOccurred())
 
 				expectedConditions := []metav1.Condition{
+					{
+						Type:               string(gatewayv1.GatewayClassReasonSupportedVersion),
+						Status:             metav1.ConditionTrue,
+						ObservedGeneration: 0,
+						LastTransitionTime: fakeClockTime,
+						Reason:             "SupportedVersion",
+						Message:            "Gateway API CRD versions are supported",
+					},
 					{
 						Type:               string(gatewayv1.GatewayClassConditionStatusAccepted),
 						Status:             metav1.ConditionFalse,
 						ObservedGeneration: 0,
 						LastTransitionTime: fakeClockTime,
 						Reason:             string(conditions.GatewayClassReasonGatewayClassConflict),
-						Message:            string(conditions.GatewayClassMessageGatewayClassConflict),
+						Message:            conditions.GatewayClassMessageGatewayClassConflict,
 					},
 				}
-
 				Expect(unknownGC.Status.Conditions).To(Equal(expectedConditions))
 			})
 		})
+
+		When("upserting Gateway API CRD that is not a supported major version", func() {
+			It("should set the SupportedVersion and Accepted statuses to false on GatewayClass", func() {
+				itShouldUpsertCRD("v99.0.0", false /* accepted */)
+			})
+		})
+
+		When("upserting Gateway API CRD that is not a supported minor version", func() {
+			It("should set the SupportedVersion status to false and Accepted status to true on GatewayClass", func() {
+				itShouldUpsertCRD("1.99.0", true /* accepted */)
+			})
+		})
 	})
 
 	Describe("Edge cases", func() {
@@ -374,7 +497,7 @@ var _ = Describe("handler", func() {
 				}
 
 				err := k8sclient.Create(context.Background(), dep)
-				Expect(err).ShouldNot(HaveOccurred())
+				Expect(err).ToNot(HaveOccurred())
 
 				itShouldPanicWhenUpsertingGateway(gwNsName)
 			})
@@ -395,7 +518,7 @@ var _ = Describe("handler", func() {
 				}
 
 				err := k8sclient.Delete(context.Background(), dep)
-				Expect(err).ShouldNot(HaveOccurred())
+				Expect(err).ToNot(HaveOccurred())
 
 				batch := []interface{}{
 					&events.DeleteEvent{
diff --git a/internal/mode/provisioner/manager.go b/internal/mode/provisioner/manager.go
index 1454111578..1bca844bac 100644
--- a/internal/mode/provisioner/manager.go
+++ b/internal/mode/provisioner/manager.go
@@ -5,8 +5,10 @@ import (
 
 	"github.com/go-logr/logr"
 	v1 "k8s.io/api/apps/v1"
+	apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
 	ctlr "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -17,6 +19,7 @@ import (
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/controller"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/controller/predicate"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/events"
+	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/gatewayclass"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/status"
 )
 
@@ -39,6 +42,7 @@ func StartManager(cfg Config) error {
 	scheme := runtime.NewScheme()
 	utilruntime.Must(gatewayv1.AddToScheme(scheme))
 	utilruntime.Must(v1.AddToScheme(scheme))
+	utilruntime.Must(apiext.AddToScheme(scheme))
 
 	options := manager.Options{
 		Scheme: scheme,
@@ -51,6 +55,11 @@ func StartManager(cfg Config) error {
 		return fmt.Errorf("cannot build runtime manager: %w", err)
 	}
 
+	crdWithGVK := apiext.CustomResourceDefinition{}
+	crdWithGVK.SetGroupVersionKind(
+		schema.GroupVersionKind{Group: apiext.GroupName, Version: "v1", Kind: "CustomResourceDefinition"},
+	)
+
 	// Note: for any new object type or a change to the existing one,
 	// make sure to also update firstBatchPreparer creation below
 	controllerRegCfgs := []struct {
@@ -66,6 +75,15 @@ func StartManager(cfg Config) error {
 		{
 			objectType: &gatewayv1.Gateway{},
 		},
+		{
+			objectType: &crdWithGVK,
+			options: []controller.Option{
+				controller.WithOnlyMetadata(),
+				controller.WithK8sPredicate(
+					predicate.AnnotationPredicate{Annotation: gatewayclass.BundleVersionAnnotation},
+				),
+			},
+		},
 	}
 
 	ctx := ctlr.SetupSignalHandler()
@@ -83,6 +101,15 @@ func StartManager(cfg Config) error {
 		}
 	}
 
+	partialObjectMetadataList := &metav1.PartialObjectMetadataList{}
+	partialObjectMetadataList.SetGroupVersionKind(
+		schema.GroupVersionKind{
+			Group:   apiext.GroupName,
+			Version: "v1",
+			Kind:    "CustomResourceDefinition",
+		},
+	)
+
 	firstBatchPreparer := events.NewFirstEventBatchPreparerImpl(
 		mgr.GetCache(),
 		[]client.Object{
@@ -90,6 +117,7 @@ func StartManager(cfg Config) error {
 		},
 		[]client.ObjectList{
 			&gatewayv1.GatewayList{},
+			partialObjectMetadataList,
 		},
 	)
 
diff --git a/internal/mode/provisioner/store.go b/internal/mode/provisioner/store.go
index a3e521f4e2..6585f21017 100644
--- a/internal/mode/provisioner/store.go
+++ b/internal/mode/provisioner/store.go
@@ -3,6 +3,7 @@ package provisioner
 import (
 	"fmt"
 
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	v1 "sigs.k8s.io/gateway-api/apis/v1"
@@ -14,12 +15,14 @@ import (
 type store struct {
 	gatewayClasses map[types.NamespacedName]*v1.GatewayClass
 	gateways       map[types.NamespacedName]*v1.Gateway
+	crdMetadata    map[types.NamespacedName]*metav1.PartialObjectMetadata
 }
 
 func newStore() *store {
 	return &store{
 		gatewayClasses: make(map[types.NamespacedName]*v1.GatewayClass),
 		gateways:       make(map[types.NamespacedName]*v1.Gateway),
+		crdMetadata:    make(map[types.NamespacedName]*metav1.PartialObjectMetadata),
 	}
 }
 
@@ -32,6 +35,8 @@ func (s *store) update(batch events.EventBatch) {
 				s.gatewayClasses[client.ObjectKeyFromObject(obj)] = obj
 			case *v1.Gateway:
 				s.gateways[client.ObjectKeyFromObject(obj)] = obj
+			case *metav1.PartialObjectMetadata:
+				s.crdMetadata[client.ObjectKeyFromObject(obj)] = obj
 			default:
 				panic(fmt.Errorf("unknown resource type %T", e.Resource))
 			}
@@ -41,6 +46,8 @@ func (s *store) update(batch events.EventBatch) {
 				delete(s.gatewayClasses, e.NamespacedName)
 			case *v1.Gateway:
 				delete(s.gateways, e.NamespacedName)
+			case *metav1.PartialObjectMetadata:
+				delete(s.crdMetadata, e.NamespacedName)
 			default:
 				panic(fmt.Errorf("unknown resource type %T", e.Type))
 			}
diff --git a/internal/mode/static/build_statuses.go b/internal/mode/static/build_statuses.go
index 5589922161..a5ec222cdd 100644
--- a/internal/mode/static/build_statuses.go
+++ b/internal/mode/static/build_statuses.go
@@ -61,7 +61,7 @@ func buildGatewayAPIStatuses(
 			parentStatuses = append(parentStatuses, status.ParentStatus{
 				GatewayNsName: ref.Gateway,
 				SectionName:   routeRef.SectionName,
-				Conditions:    staticConds.DeduplicateConditions(allConds),
+				Conditions:    conditions.DeduplicateConditions(allConds),
 			})
 		}
 
@@ -91,7 +91,7 @@ func buildGatewayClassStatuses(
 		conds = append(conds, gc.Conditions...)
 
 		statuses[client.ObjectKeyFromObject(gc.Source)] = status.GatewayClassStatus{
-			Conditions:         staticConds.DeduplicateConditions(conds),
+			Conditions:         conditions.DeduplicateConditions(conds),
 			ObservedGeneration: gc.Source.Generation,
 		}
 	}
@@ -136,7 +136,7 @@ func buildGatewayStatus(
 ) status.GatewayStatus {
 	if !gateway.Valid {
 		return status.GatewayStatus{
-			Conditions:         staticConds.DeduplicateConditions(gateway.Conditions),
+			Conditions:         conditions.DeduplicateConditions(gateway.Conditions),
 			ObservedGeneration: gateway.Source.Generation,
 		}
 	}
@@ -163,7 +163,7 @@ func buildGatewayStatus(
 
 		listenerStatuses[name] = status.ListenerStatus{
 			AttachedRoutes: int32(len(l.Routes)),
-			Conditions:     staticConds.DeduplicateConditions(conds),
+			Conditions:     conditions.DeduplicateConditions(conds),
 			SupportedKinds: l.SupportedKinds,
 		}
 	}
@@ -183,7 +183,7 @@ func buildGatewayStatus(
 	}
 
 	return status.GatewayStatus{
-		Conditions:         staticConds.DeduplicateConditions(gwConds),
+		Conditions:         conditions.DeduplicateConditions(gwConds),
 		ListenerStatuses:   listenerStatuses,
 		Addresses:          gwAddresses,
 		ObservedGeneration: gateway.Source.Generation,
diff --git a/internal/mode/static/manager.go b/internal/mode/static/manager.go
index e293291663..9667283226 100644
--- a/internal/mode/static/manager.go
+++ b/internal/mode/static/manager.go
@@ -9,9 +9,11 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 	apiv1 "k8s.io/api/core/v1"
 	discoveryV1 "k8s.io/api/discovery/v1"
+	apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
 	"k8s.io/apimachinery/pkg/util/wait"
@@ -31,6 +33,7 @@ import (
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/controller/index"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/controller/predicate"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/events"
+	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/gatewayclass"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/status"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/config"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/metrics/collectors"
@@ -57,6 +60,7 @@ func init() {
 	utilruntime.Must(apiv1.AddToScheme(scheme))
 	utilruntime.Must(discoveryV1.AddToScheme(scheme))
 	utilruntime.Must(ngfAPI.AddToScheme(scheme))
+	utilruntime.Must(apiext.AddToScheme(scheme))
 }
 
 func StartManager(cfg config.Config) error {
@@ -244,6 +248,11 @@ func registerControllers(
 		options    []controller.Option
 	}
 
+	crdWithGVK := apiext.CustomResourceDefinition{}
+	crdWithGVK.SetGroupVersionKind(
+		schema.GroupVersionKind{Group: apiext.GroupName, Version: "v1", Kind: "CustomResourceDefinition"},
+	)
+
 	// Note: for any new object type or a change to the existing one,
 	// make sure to also update prepareFirstEventBatchPreparerArgs()
 	controllerRegCfgs := []ctlrCfg{
@@ -304,6 +313,15 @@ func registerControllers(
 		{
 			objectType: &gatewayv1beta1.ReferenceGrant{},
 		},
+		{
+			objectType: &crdWithGVK,
+			options: []controller.Option{
+				controller.WithOnlyMetadata(),
+				controller.WithK8sPredicate(
+					predicate.AnnotationPredicate{Annotation: gatewayclass.BundleVersionAnnotation},
+				),
+			},
+		},
 	}
 
 	if cfg.ConfigName != "" {
@@ -346,6 +364,16 @@ func prepareFirstEventBatchPreparerArgs(
 	objects := []client.Object{
 		&gatewayv1.GatewayClass{ObjectMeta: metav1.ObjectMeta{Name: gcName}},
 	}
+
+	partialObjectMetadataList := &metav1.PartialObjectMetadataList{}
+	partialObjectMetadataList.SetGroupVersionKind(
+		schema.GroupVersionKind{
+			Group:   apiext.GroupName,
+			Version: "v1",
+			Kind:    "CustomResourceDefinition",
+		},
+	)
+
 	objectLists := []client.ObjectList{
 		&apiv1.ServiceList{},
 		&apiv1.SecretList{},
@@ -353,6 +381,7 @@ func prepareFirstEventBatchPreparerArgs(
 		&discoveryV1.EndpointSliceList{},
 		&gatewayv1.HTTPRouteList{},
 		&gatewayv1beta1.ReferenceGrantList{},
+		partialObjectMetadataList,
 	}
 
 	if gwNsName == nil {
diff --git a/internal/mode/static/manager_test.go b/internal/mode/static/manager_test.go
index fd8e540adb..3191a2e01d 100644
--- a/internal/mode/static/manager_test.go
+++ b/internal/mode/static/manager_test.go
@@ -6,7 +6,9 @@ import (
 	. "github.com/onsi/gomega"
 	apiv1 "k8s.io/api/core/v1"
 	discoveryV1 "k8s.io/api/discovery/v1"
+	apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
@@ -19,6 +21,15 @@ import (
 func TestPrepareFirstEventBatchPreparerArgs(t *testing.T) {
 	const gcName = "nginx"
 
+	partialObjectMetadataList := &metav1.PartialObjectMetadataList{}
+	partialObjectMetadataList.SetGroupVersionKind(
+		schema.GroupVersionKind{
+			Group:   apiext.GroupName,
+			Version: "v1",
+			Kind:    "CustomResourceDefinition",
+		},
+	)
+
 	tests := []struct {
 		name                string
 		gwNsName            *types.NamespacedName
@@ -39,6 +50,7 @@ func TestPrepareFirstEventBatchPreparerArgs(t *testing.T) {
 				&gatewayv1.HTTPRouteList{},
 				&gatewayv1.GatewayList{},
 				&gatewayv1beta1.ReferenceGrantList{},
+				partialObjectMetadataList,
 			},
 		},
 		{
@@ -58,6 +70,7 @@ func TestPrepareFirstEventBatchPreparerArgs(t *testing.T) {
 				&discoveryV1.EndpointSliceList{},
 				&gatewayv1.HTTPRouteList{},
 				&gatewayv1beta1.ReferenceGrantList{},
+				partialObjectMetadataList,
 			},
 		},
 	}
diff --git a/internal/mode/static/nginx/config/execute_test.go b/internal/mode/static/nginx/config/execute_test.go
index 4456c7370a..61538973c3 100644
--- a/internal/mode/static/nginx/config/execute_test.go
+++ b/internal/mode/static/nginx/config/execute_test.go
@@ -20,7 +20,7 @@ func TestExecute(t *testing.T) {
 func TestExecutePanics(t *testing.T) {
 	defer func() {
 		g := NewWithT(t)
-		g.Expect(recover()).ShouldNot(BeNil())
+		g.Expect(recover()).ToNot(BeNil())
 	}()
 
 	_ = execute(serversTemplate, "not-correct-data")
diff --git a/internal/mode/static/nginx/config/servers_test.go b/internal/mode/static/nginx/config/servers_test.go
index ef9477f739..3cb1a1c5ca 100644
--- a/internal/mode/static/nginx/config/servers_test.go
+++ b/internal/mode/static/nginx/config/servers_test.go
@@ -440,7 +440,7 @@ func TestCreateServers(t *testing.T) {
 	expectedMatchString := func(m []httpMatch) string {
 		g := NewWithT(t)
 		b, err := json.Marshal(m)
-		g.Expect(err).ShouldNot(HaveOccurred())
+		g.Expect(err).ToNot(HaveOccurred())
 		return string(b)
 	}
 
diff --git a/internal/mode/static/nginx/file/manager_test.go b/internal/mode/static/nginx/file/manager_test.go
index 07f0478ae9..12f51a0f4f 100644
--- a/internal/mode/static/nginx/file/manager_test.go
+++ b/internal/mode/static/nginx/file/manager_test.go
@@ -23,7 +23,7 @@ var _ = Describe("EventHandler", func() {
 
 		ensureFiles := func(files []file.File) {
 			entries, err := os.ReadDir(tmpDir)
-			Expect(err).ShouldNot(HaveOccurred())
+			Expect(err).ToNot(HaveOccurred())
 			Expect(entries).Should(HaveLen(len(files)))
 
 			entriesMap := make(map[string]os.DirEntry)
@@ -36,7 +36,7 @@ var _ = Describe("EventHandler", func() {
 				Expect(ok).Should(BeTrue())
 
 				info, err := os.Stat(f.Path)
-				Expect(err).ShouldNot(HaveOccurred())
+				Expect(err).ToNot(HaveOccurred())
 
 				Expect(info.IsDir()).To(BeFalse())
 
@@ -47,7 +47,7 @@ var _ = Describe("EventHandler", func() {
 				}
 
 				bytes, err := os.ReadFile(f.Path)
-				Expect(err).ShouldNot(HaveOccurred())
+				Expect(err).ToNot(HaveOccurred())
 				Expect(bytes).To(Equal(f.Content))
 			}
 		}
@@ -89,7 +89,7 @@ var _ = Describe("EventHandler", func() {
 			files := []file.File{regular1, regular2, secret}
 
 			err := mgr.ReplaceFiles(files)
-			Expect(err).ShouldNot(HaveOccurred())
+			Expect(err).ToNot(HaveOccurred())
 
 			ensureFiles(files)
 		})
@@ -102,7 +102,7 @@ var _ = Describe("EventHandler", func() {
 			}
 
 			err := mgr.ReplaceFiles(files)
-			Expect(err).ShouldNot(HaveOccurred())
+			Expect(err).ToNot(HaveOccurred())
 
 			ensureFiles(files)
 			ensureNotExist(regular1)
@@ -110,7 +110,7 @@ var _ = Describe("EventHandler", func() {
 
 		It("should remove all files", func() {
 			err := mgr.ReplaceFiles(nil)
-			Expect(err).ShouldNot(HaveOccurred())
+			Expect(err).ToNot(HaveOccurred())
 
 			ensureNotExist(regular2, regular3, secret)
 		})
@@ -181,7 +181,7 @@ var _ = Describe("EventHandler", func() {
 				// to kick off removing, we need to successfully write files beforehand
 				if fakeOSMgr.RemoveStub != nil {
 					err := mgr.ReplaceFiles(files)
-					Expect(err).ShouldNot(HaveOccurred())
+					Expect(err).ToNot(HaveOccurred())
 				}
 
 				err := mgr.ReplaceFiles(files)
diff --git a/internal/mode/static/state/change_processor.go b/internal/mode/static/state/change_processor.go
index 42d92f8fce..d2ba8eed35 100644
--- a/internal/mode/static/state/change_processor.go
+++ b/internal/mode/static/state/change_processor.go
@@ -7,6 +7,8 @@ import (
 	"github.com/go-logr/logr"
 	apiv1 "k8s.io/api/core/v1"
 	discoveryV1 "k8s.io/api/discovery/v1"
+	apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
@@ -18,6 +20,7 @@ import (
 
 	gwapivalidation "sigs.k8s.io/gateway-api/apis/v1/validation"
 
+	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/gatewayclass"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/graph"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/relationship"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/validation"
@@ -95,6 +98,7 @@ func NewChangeProcessorImpl(cfg ChangeProcessorConfig) *ChangeProcessorImpl {
 		Namespaces:      make(map[types.NamespacedName]*apiv1.Namespace),
 		ReferenceGrants: make(map[types.NamespacedName]*v1beta1.ReferenceGrant),
 		Secrets:         make(map[types.NamespacedName]*apiv1.Secret),
+		CRDMetadata:     make(map[types.NamespacedName]*metav1.PartialObjectMetadata),
 	}
 
 	extractGVK := func(obj client.Object) schema.GroupVersionKind {
@@ -110,54 +114,59 @@ func NewChangeProcessorImpl(cfg ChangeProcessorConfig) *ChangeProcessorImpl {
 		clusterState: clusterStore,
 	}
 
-	triggerStateChange := func(objType client.Object, nsname types.NamespacedName) bool {
-		return processor.latestGraph != nil && processor.latestGraph.IsReferenced(objType, nsname)
+	isReferenced := func(obj client.Object) bool {
+		nsname := types.NamespacedName{Name: obj.GetName(), Namespace: obj.GetNamespace()}
+		return processor.latestGraph != nil && processor.latestGraph.IsReferenced(obj, nsname)
 	}
 
 	trackingUpdater := newChangeTrackingUpdater(
 		cfg.RelationshipCapturer,
-		triggerStateChange,
 		extractGVK,
 		[]changeTrackingUpdaterObjectTypeCfg{
 			{
-				gvk:               extractGVK(&v1.GatewayClass{}),
-				store:             newObjectStoreMapAdapter(clusterStore.GatewayClasses),
-				trackUpsertDelete: true,
+				gvk:       extractGVK(&v1.GatewayClass{}),
+				store:     newObjectStoreMapAdapter(clusterStore.GatewayClasses),
+				predicate: generationChangedPredicate{},
 			},
 			{
-				gvk:               extractGVK(&v1.Gateway{}),
-				store:             newObjectStoreMapAdapter(clusterStore.Gateways),
-				trackUpsertDelete: true,
+				gvk:       extractGVK(&v1.Gateway{}),
+				store:     newObjectStoreMapAdapter(clusterStore.Gateways),
+				predicate: generationChangedPredicate{},
 			},
 			{
-				gvk:               extractGVK(&v1.HTTPRoute{}),
-				store:             newObjectStoreMapAdapter(clusterStore.HTTPRoutes),
-				trackUpsertDelete: true,
+				gvk:       extractGVK(&v1.HTTPRoute{}),
+				store:     newObjectStoreMapAdapter(clusterStore.HTTPRoutes),
+				predicate: generationChangedPredicate{},
 			},
 			{
-				gvk:               extractGVK(&v1beta1.ReferenceGrant{}),
-				store:             newObjectStoreMapAdapter(clusterStore.ReferenceGrants),
-				trackUpsertDelete: true,
+				gvk:       extractGVK(&v1beta1.ReferenceGrant{}),
+				store:     newObjectStoreMapAdapter(clusterStore.ReferenceGrants),
+				predicate: generationChangedPredicate{},
 			},
 			{
-				gvk:               extractGVK(&apiv1.Namespace{}),
-				store:             newObjectStoreMapAdapter(clusterStore.Namespaces),
-				trackUpsertDelete: false,
+				gvk:       extractGVK(&apiv1.Namespace{}),
+				store:     newObjectStoreMapAdapter(clusterStore.Namespaces),
+				predicate: nil,
 			},
 			{
-				gvk:               extractGVK(&apiv1.Service{}),
-				store:             newObjectStoreMapAdapter(clusterStore.Services),
-				trackUpsertDelete: false,
+				gvk:       extractGVK(&apiv1.Service{}),
+				store:     newObjectStoreMapAdapter(clusterStore.Services),
+				predicate: nil,
 			},
 			{
-				gvk:               extractGVK(&discoveryV1.EndpointSlice{}),
-				store:             nil,
-				trackUpsertDelete: false,
+				gvk:       extractGVK(&discoveryV1.EndpointSlice{}),
+				store:     nil,
+				predicate: nil,
 			},
 			{
-				gvk:               extractGVK(&apiv1.Secret{}),
-				store:             newObjectStoreMapAdapter(clusterStore.Secrets),
-				trackUpsertDelete: false,
+				gvk:       extractGVK(&apiv1.Secret{}),
+				store:     newObjectStoreMapAdapter(clusterStore.Secrets),
+				predicate: funcPredicate{stateChanged: isReferenced},
+			},
+			{
+				gvk:       extractGVK(&apiext.CustomResourceDefinition{}),
+				store:     newObjectStoreMapAdapter(clusterStore.CRDMetadata),
+				predicate: annotationChangedPredicate{annotation: gatewayclass.BundleVersionAnnotation},
 			},
 		},
 	)
diff --git a/internal/mode/static/state/change_processor_test.go b/internal/mode/static/state/change_processor_test.go
index 1e50371b2b..9456aa6084 100644
--- a/internal/mode/static/state/change_processor_test.go
+++ b/internal/mode/static/state/change_processor_test.go
@@ -6,6 +6,7 @@ import (
 	"github.com/onsi/gomega/format"
 	apiv1 "k8s.io/api/core/v1"
 	discoveryV1 "k8s.io/api/discovery/v1"
+	apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
@@ -19,6 +20,7 @@ import (
 
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/conditions"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/controller/index"
+	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/gatewayclass"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/helpers"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state"
 	staticConds "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/conditions"
@@ -186,6 +188,7 @@ func createScheme() *runtime.Scheme {
 	utilruntime.Must(v1beta1.AddToScheme(scheme))
 	utilruntime.Must(apiv1.AddToScheme(scheme))
 	utilruntime.Must(discoveryV1.AddToScheme(scheme))
+	utilruntime.Must(apiext.AddToScheme(scheme))
 
 	return scheme
 }
@@ -270,14 +273,15 @@ var _ = Describe("ChangeProcessor", func() {
 
 		Describe("Process gateway resources", Ordered, func() {
 			var (
-				gcUpdated                        *v1.GatewayClass
-				diffNsTLSSecret, sameNsTLSSecret *apiv1.Secret
-				hr1, hr1Updated, hr2             *v1.HTTPRoute
-				gw1, gw1Updated, gw2             *v1.Gateway
-				refGrant1, refGrant2             *v1beta1.ReferenceGrant
-				expGraph                         *graph.Graph
-				expRouteHR1, expRouteHR2         *graph.Route
-				hr1Name, hr2Name                 types.NamespacedName
+				gcUpdated                           *v1.GatewayClass
+				diffNsTLSSecret, sameNsTLSSecret    *apiv1.Secret
+				hr1, hr1Updated, hr2                *v1.HTTPRoute
+				gw1, gw1Updated, gw2                *v1.Gateway
+				refGrant1, refGrant2                *v1beta1.ReferenceGrant
+				expGraph                            *graph.Graph
+				expRouteHR1, expRouteHR2            *graph.Route
+				hr1Name, hr2Name                    types.NamespacedName
+				gatewayAPICRD, gatewayAPICRDUpdated *metav1.PartialObjectMetadata
 			)
 			BeforeAll(func() {
 				gcUpdated = gc.DeepCopy()
@@ -375,6 +379,22 @@ var _ = Describe("ChangeProcessor", func() {
 				gw1Updated.Generation++
 
 				gw2 = createGatewayWithTLSListener("gateway-2", sameNsTLSSecret)
+
+				gatewayAPICRD = &metav1.PartialObjectMetadata{
+					TypeMeta: metav1.TypeMeta{
+						Kind:       "CustomResourceDefinition",
+						APIVersion: "apiextensions.k8s.io/v1",
+					},
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "gatewayclasses.gateway.networking.k8s.io",
+						Annotations: map[string]string{
+							gatewayclass.BundleVersionAnnotation: gatewayclass.SupportedVersion,
+						},
+					},
+				}
+
+				gatewayAPICRDUpdated = gatewayAPICRD.DeepCopy()
+				gatewayAPICRDUpdated.Annotations[gatewayclass.BundleVersionAnnotation] = "v1.99.0"
 			})
 			BeforeEach(func() {
 				expRouteHR1 = &graph.Route{
@@ -479,7 +499,6 @@ var _ = Describe("ChangeProcessor", func() {
 					ReferencedSecrets: map[types.NamespacedName]*graph.Secret{},
 				}
 			})
-
 			When("no upsert has occurred", func() {
 				It("returns nil graph", func() {
 					changed, graphCfg := processor.Process()
@@ -488,6 +507,15 @@ var _ = Describe("ChangeProcessor", func() {
 				})
 			})
 			When("GatewayClass doesn't exist", func() {
+				When("Gateway API CRD is added", func() {
+					It("returns empty graph", func() {
+						processor.CaptureUpsertChange(gatewayAPICRD)
+
+						changed, graphCfg := processor.Process()
+						Expect(changed).To(BeTrue())
+						Expect(helpers.Diff(&graph.Graph{}, graphCfg)).To(BeEmpty())
+					})
+				})
 				When("Gateways don't exist", func() {
 					When("the first HTTPRoute is upserted", func() {
 						It("returns empty graph", func() {
@@ -606,7 +634,6 @@ var _ = Describe("ChangeProcessor", func() {
 					Expect(helpers.Diff(expGraph, graphCfg)).To(BeEmpty())
 				})
 			})
-
 			When("the ReferenceGrant allowing the hr1 to reference the Service in different ns is upserted", func() {
 				It("returns updated graph", func() {
 					processor.CaptureUpsertChange(refGrant2)
@@ -620,6 +647,48 @@ var _ = Describe("ChangeProcessor", func() {
 					Expect(helpers.Diff(expGraph, graphCfg)).To(BeEmpty())
 				})
 			})
+			When("the Gateway API CRD with bundle version annotation change is processed", func() {
+				It("returns updated graph", func() {
+					processor.CaptureUpsertChange(gatewayAPICRDUpdated)
+
+					expGraph.ReferencedSecrets[client.ObjectKeyFromObject(diffNsTLSSecret)] = &graph.Secret{
+						Source: diffNsTLSSecret,
+					}
+
+					expGraph.GatewayClass.Conditions = conditions.NewGatewayClassSupportedVersionBestEffort(
+						gatewayclass.SupportedVersion,
+					)
+
+					changed, graphCfg := processor.Process()
+					Expect(changed).To(BeTrue())
+					Expect(helpers.Diff(expGraph, graphCfg)).To(BeEmpty())
+				})
+			})
+			When("the Gateway API CRD without bundle version annotation change is processed", func() {
+				It("returns nil graph", func() {
+					gatewayAPICRDSameVersion := gatewayAPICRDUpdated.DeepCopy()
+
+					processor.CaptureUpsertChange(gatewayAPICRDSameVersion)
+
+					changed, graphCfg := processor.Process()
+					Expect(changed).To(BeFalse())
+					Expect(graphCfg).To(BeNil())
+				})
+			})
+			When("the Gateway API CRD with bundle version annotation change is processed", func() {
+				It("returns updated graph", func() {
+					// change back to supported version
+					processor.CaptureUpsertChange(gatewayAPICRD)
+
+					expGraph.ReferencedSecrets[client.ObjectKeyFromObject(diffNsTLSSecret)] = &graph.Secret{
+						Source: diffNsTLSSecret,
+					}
+
+					changed, graphCfg := processor.Process()
+					Expect(changed).To(BeTrue())
+					Expect(helpers.Diff(expGraph, graphCfg)).To(BeEmpty())
+				})
+			})
 			When("the first HTTPRoute without a generation changed is processed", func() {
 				It("returns nil graph", func() {
 					hr1UpdatedSameGen := hr1.DeepCopy()
diff --git a/internal/mode/static/state/changed_predicate.go b/internal/mode/static/state/changed_predicate.go
new file mode 100644
index 0000000000..6b253d968e
--- /dev/null
+++ b/internal/mode/static/state/changed_predicate.go
@@ -0,0 +1,68 @@
+package state
+
+import "sigs.k8s.io/controller-runtime/pkg/client"
+
+// stateChangedPredicate determines whether upsert and delete events constitute a change in state.
+type stateChangedPredicate interface {
+	// upsert returns true if the newObject changes state.
+	upsert(oldObject, newObject client.Object) bool
+	// delete returns true if the deletion of the object changes state.
+	delete(object client.Object) bool
+}
+
+// funcPredicate applies the stateChanged function on upsert and delete. On upsert, the newObject is passed.
+// Implements stateChangedPredicate.
+type funcPredicate struct {
+	stateChanged func(object client.Object) bool
+}
+
+func (f funcPredicate) upsert(_, newObject client.Object) bool {
+	return f.stateChanged(newObject)
+}
+
+func (f funcPredicate) delete(object client.Object) bool {
+	return f.stateChanged(object)
+}
+
+// generationChangedPredicate implements stateChangedPredicate based on the generation of the object.
+// This predicate will return true on upsert if the object's generation has changed.
+// It always returns true on delete.
+type generationChangedPredicate struct{}
+
+func (generationChangedPredicate) delete(_ client.Object) bool { return true }
+
+func (generationChangedPredicate) upsert(oldObject, newObject client.Object) bool {
+	if oldObject == nil {
+		return true
+	}
+
+	if newObject == nil {
+		panic("Cannot determine if generation has changed on upsert because new object is nil")
+	}
+
+	return newObject.GetGeneration() != oldObject.GetGeneration()
+}
+
+// annotationChangedPredicate implements stateChangedPredicate based on the value of the annotation provided.
+// This predicate will return true on upsert if the annotation's value has changed.
+// It always returns true on delete.
+type annotationChangedPredicate struct {
+	annotation string
+}
+
+func (a annotationChangedPredicate) upsert(oldObject, newObject client.Object) bool {
+	if oldObject == nil {
+		return true
+	}
+
+	if newObject == nil {
+		panic("Cannot determine if annotation has changed on upsert because new object is nil")
+	}
+
+	oldAnnotation := oldObject.GetAnnotations()[a.annotation]
+	newAnnotation := newObject.GetAnnotations()[a.annotation]
+
+	return oldAnnotation != newAnnotation
+}
+
+func (a annotationChangedPredicate) delete(_ client.Object) bool { return true }
diff --git a/internal/mode/static/state/changed_predicate_test.go b/internal/mode/static/state/changed_predicate_test.go
new file mode 100644
index 0000000000..0e5142a9ff
--- /dev/null
+++ b/internal/mode/static/state/changed_predicate_test.go
@@ -0,0 +1,211 @@
+package state
+
+import (
+	"testing"
+
+	. "github.com/onsi/gomega"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+func TestFuncPredicate(t *testing.T) {
+	alwaysTrueFunc := func(object client.Object) bool { return true }
+
+	p := funcPredicate{stateChanged: alwaysTrueFunc}
+
+	g := NewWithT(t)
+
+	g.Expect(p.delete(nil)).To(BeTrue())
+	g.Expect(p.upsert(nil, nil)).To(BeTrue())
+}
+
+func TestGenerationChangedPredicate_Delete(t *testing.T) {
+	p := generationChangedPredicate{}
+
+	g := NewWithT(t)
+	g.Expect(p.delete(nil)).To(BeTrue())
+}
+
+func TestGenerationChangedPredicate_Update(t *testing.T) {
+	tests := []struct {
+		oldObj       client.Object
+		newObj       client.Object
+		name         string
+		stateChanged bool
+		expPanic     bool
+	}{
+		{
+			name: "generation has changed",
+			oldObj: &v1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+				},
+			},
+			newObj: &v1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 2,
+				},
+			},
+			stateChanged: true,
+		},
+		{
+			name: "generation has not changed",
+			oldObj: &v1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+				},
+			},
+			newObj: &v1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+				},
+			},
+			stateChanged: false,
+		},
+		{
+			name:         "old object is nil",
+			oldObj:       nil,
+			newObj:       &v1.Pod{},
+			stateChanged: true,
+		},
+		{
+			name:     "new object is nil",
+			oldObj:   &v1.Pod{},
+			newObj:   nil,
+			expPanic: true,
+		},
+	}
+
+	p := generationChangedPredicate{}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			g := NewWithT(t)
+			if test.expPanic {
+				upsert := func() {
+					p.upsert(test.oldObj, test.newObj)
+				}
+				g.Expect(upsert).Should(Panic())
+			} else {
+				g.Expect(p.upsert(test.oldObj, test.newObj)).To(Equal(test.stateChanged))
+			}
+		})
+	}
+}
+
+func TestAnnotationChangedPredicate_Delete(t *testing.T) {
+	p := annotationChangedPredicate{}
+
+	g := NewWithT(t)
+	g.Expect(p.delete(nil)).To(BeTrue())
+}
+
+func TestAnnotationChangedPredicate_Update(t *testing.T) {
+	annotation := "test"
+
+	tests := []struct {
+		oldObj       client.Object
+		newObj       client.Object
+		name         string
+		stateChanged bool
+		expPanic     bool
+	}{
+		{
+			name: "annotation has changed",
+			oldObj: &v1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{annotation: "one"},
+				},
+			},
+			newObj: &v1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{annotation: "two"},
+				},
+			},
+			stateChanged: true,
+		},
+		{
+			name:   "annotation added",
+			oldObj: &v1.Pod{},
+			newObj: &v1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{annotation: "one"},
+				},
+			},
+			stateChanged: true,
+		},
+		{
+			name: "annotation deleted",
+			oldObj: &v1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{annotation: "one"},
+				},
+			},
+			newObj:       &v1.Pod{},
+			stateChanged: true,
+		},
+		{
+			name:         "old object is nil",
+			oldObj:       nil,
+			newObj:       &v1.Pod{},
+			stateChanged: true,
+		},
+		{
+			name: "diff annotation changed",
+			oldObj: &v1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{"diff": "one"},
+				},
+			},
+			newObj: &v1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{"diff": "two"},
+				},
+			},
+			stateChanged: false,
+		},
+		{
+			name:         "no annotations",
+			oldObj:       &v1.Pod{},
+			newObj:       &v1.Pod{},
+			stateChanged: false,
+		},
+		{
+			name: "annotation has not changed",
+			oldObj: &v1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{annotation: "one"},
+				},
+			},
+			newObj: &v1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{annotation: "one"},
+				},
+			},
+			stateChanged: false,
+		},
+		{
+			name:     "new object is nil",
+			oldObj:   &v1.Pod{},
+			newObj:   nil,
+			expPanic: true,
+		},
+	}
+
+	p := annotationChangedPredicate{annotation: annotation}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			g := NewWithT(t)
+			if test.expPanic {
+				upsert := func() {
+					p.upsert(test.oldObj, test.newObj)
+				}
+				g.Expect(upsert).Should(Panic())
+			} else {
+				g.Expect(p.upsert(test.oldObj, test.newObj)).To(Equal(test.stateChanged))
+			}
+		})
+	}
+}
diff --git a/internal/mode/static/state/conditions/conditions.go b/internal/mode/static/state/conditions/conditions.go
index 2703da1efc..25ade0b204 100644
--- a/internal/mode/static/state/conditions/conditions.go
+++ b/internal/mode/static/state/conditions/conditions.go
@@ -59,38 +59,6 @@ const (
 		"is programmed again"
 )
 
-// DeduplicateConditions removes duplicate conditions based on the condition type.
-// The last condition wins. The order of conditions is preserved.
-func DeduplicateConditions(conds []conditions.Condition) []conditions.Condition {
-	type elem struct {
-		cond       conditions.Condition
-		reverseIdx int
-	}
-
-	uniqueElems := make(map[string]elem)
-
-	idx := 0
-	for i := len(conds) - 1; i >= 0; i-- {
-		if _, exist := uniqueElems[conds[i].Type]; exist {
-			continue
-		}
-
-		uniqueElems[conds[i].Type] = elem{
-			cond:       conds[i],
-			reverseIdx: idx,
-		}
-		idx++
-	}
-
-	result := make([]conditions.Condition, len(uniqueElems))
-
-	for _, el := range uniqueElems {
-		result[len(result)-el.reverseIdx-1] = el.cond
-	}
-
-	return result
-}
-
 // NewTODO returns a Condition that can be used as a placeholder for a condition that is not yet implemented.
 func NewTODO(msg string) conditions.Condition {
 	return conditions.Condition{
diff --git a/internal/mode/static/state/graph/gatewayclass.go b/internal/mode/static/state/graph/gatewayclass.go
index 929bf1611b..fc3313f269 100644
--- a/internal/mode/static/state/graph/gatewayclass.go
+++ b/internal/mode/static/state/graph/gatewayclass.go
@@ -1,12 +1,14 @@
 package graph
 
 import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apimachinery/pkg/util/validation/field"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	v1 "sigs.k8s.io/gateway-api/apis/v1"
 
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/conditions"
+	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/gatewayclass"
 	staticConds "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/conditions"
 )
 
@@ -56,30 +58,39 @@ func processGatewayClasses(
 	return processedGwClasses, gcExists
 }
 
-func buildGatewayClass(gc *v1.GatewayClass) *GatewayClass {
+func buildGatewayClass(
+	gc *v1.GatewayClass,
+	crdVersions map[types.NamespacedName]*metav1.PartialObjectMetadata,
+) *GatewayClass {
 	if gc == nil {
 		return nil
 	}
 
-	var conds []conditions.Condition
-
-	valErr := validateGatewayClass(gc)
-	if valErr != nil {
-		conds = append(conds, staticConds.NewGatewayClassInvalidParameters(valErr.Error()))
-	}
+	conds, valid := validateGatewayClass(gc, crdVersions)
 
 	return &GatewayClass{
 		Source:     gc,
-		Valid:      valErr == nil,
+		Valid:      valid,
 		Conditions: conds,
 	}
 }
 
-func validateGatewayClass(gc *v1.GatewayClass) error {
+func validateGatewayClass(
+	gc *v1.GatewayClass,
+	crdVersions map[types.NamespacedName]*metav1.PartialObjectMetadata,
+) ([]conditions.Condition, bool) {
+	var conds []conditions.Condition
+
+	valid := true
+
 	if gc.Spec.ParametersRef != nil {
 		path := field.NewPath("spec").Child("parametersRef")
-		return field.Forbidden(path, "parametersRef is not supported")
+		err := field.Forbidden(path, "parametersRef is not supported")
+		conds = append(conds, staticConds.NewGatewayClassInvalidParameters(err.Error()))
+		valid = false
 	}
 
-	return nil
+	supportedVersionConds, versionsValid := gatewayclass.ValidateCRDVersions(crdVersions)
+
+	return append(conds, supportedVersionConds...), valid && versionsValid
 }
diff --git a/internal/mode/static/state/graph/gatewayclass_test.go b/internal/mode/static/state/graph/gatewayclass_test.go
index 4ad77bd3c9..11c4feb321 100644
--- a/internal/mode/static/state/graph/gatewayclass_test.go
+++ b/internal/mode/static/state/graph/gatewayclass_test.go
@@ -10,6 +10,7 @@ import (
 	v1 "sigs.k8s.io/gateway-api/apis/v1"
 
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/conditions"
+	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/gatewayclass"
 	"github.com/nginxinc/nginx-gateway-fabric/internal/framework/helpers"
 	staticConds "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/conditions"
 )
@@ -127,13 +128,35 @@ func TestBuildGatewayClass(t *testing.T) {
 		},
 	}
 
+	validCRDs := map[types.NamespacedName]*metav1.PartialObjectMetadata{
+		{Name: "gateways.gateway.networking.k8s.io"}: {
+			ObjectMeta: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					gatewayclass.BundleVersionAnnotation: gatewayclass.SupportedVersion,
+				},
+			},
+		},
+	}
+
+	invalidCRDs := map[types.NamespacedName]*metav1.PartialObjectMetadata{
+		{Name: "gateways.gateway.networking.k8s.io"}: {
+			ObjectMeta: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					gatewayclass.BundleVersionAnnotation: "v99.0.0",
+				},
+			},
+		},
+	}
+
 	tests := []struct {
-		gc       *v1.GatewayClass
-		expected *GatewayClass
-		name     string
+		gc          *v1.GatewayClass
+		crdMetadata map[types.NamespacedName]*metav1.PartialObjectMetadata
+		expected    *GatewayClass
+		name        string
 	}{
 		{
-			gc: validGC,
+			gc:          validGC,
+			crdMetadata: validCRDs,
 			expected: &GatewayClass{
 				Source: validGC,
 				Valid:  true,
@@ -146,7 +169,8 @@ func TestBuildGatewayClass(t *testing.T) {
 			name:     "no gatewayclass",
 		},
 		{
-			gc: invalidGC,
+			gc:          invalidGC,
+			crdMetadata: validCRDs,
 			expected: &GatewayClass{
 				Source: invalidGC,
 				Valid:  false,
@@ -156,7 +180,17 @@ func TestBuildGatewayClass(t *testing.T) {
 					),
 				},
 			},
-			name: "invalid gatewayclass",
+			name: "invalid gatewayclass; parameters ref",
+		},
+		{
+			gc:          validGC,
+			crdMetadata: invalidCRDs,
+			expected: &GatewayClass{
+				Source:     validGC,
+				Valid:      false,
+				Conditions: conditions.NewGatewayClassUnsupportedVersion(gatewayclass.SupportedVersion),
+			},
+			name: "invalid gatewayclass; unsupported version",
 		},
 	}
 
@@ -164,7 +198,7 @@ func TestBuildGatewayClass(t *testing.T) {
 		t.Run(test.name, func(t *testing.T) {
 			g := NewWithT(t)
 
-			result := buildGatewayClass(test.gc)
+			result := buildGatewayClass(test.gc, test.crdMetadata)
 			g.Expect(helpers.Diff(test.expected, result)).To(BeEmpty())
 		})
 	}
diff --git a/internal/mode/static/state/graph/graph.go b/internal/mode/static/state/graph/graph.go
index 8e370011e3..ae9f5f8e34 100644
--- a/internal/mode/static/state/graph/graph.go
+++ b/internal/mode/static/state/graph/graph.go
@@ -2,6 +2,7 @@ package graph
 
 import (
 	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
@@ -19,6 +20,7 @@ type ClusterState struct {
 	Namespaces      map[types.NamespacedName]*v1.Namespace
 	ReferenceGrants map[types.NamespacedName]*v1beta1.ReferenceGrant
 	Secrets         map[types.NamespacedName]*v1.Secret
+	CRDMetadata     map[types.NamespacedName]*metav1.PartialObjectMetadata
 }
 
 // Graph is a Graph-like representation of Gateway API resources.
@@ -76,7 +78,8 @@ func BuildGraph(
 		// configured GatewayClass does not reference this controller
 		return &Graph{}
 	}
-	gc := buildGatewayClass(processedGwClasses.Winner)
+
+	gc := buildGatewayClass(processedGwClasses.Winner, state.CRDMetadata)
 
 	secretResolver := newSecretResolver(state.Secrets)
 
diff --git a/internal/mode/static/state/resolver/resolver_test.go b/internal/mode/static/state/resolver/resolver_test.go
index baedda7f96..411ca190e9 100644
--- a/internal/mode/static/state/resolver/resolver_test.go
+++ b/internal/mode/static/state/resolver/resolver_test.go
@@ -126,7 +126,7 @@ func TestGetServicePort(t *testing.T) {
 	// ports exist
 	for _, p := range []int32{80, 81, 82} {
 		port, err := getServicePort(svc, p)
-		g.Expect(err).ShouldNot(HaveOccurred())
+		g.Expect(err).ToNot(HaveOccurred())
 		g.Expect(port.Port).To(Equal(p))
 	}
 
diff --git a/internal/mode/static/state/store.go b/internal/mode/static/state/store.go
index 0c6adf4731..351717c244 100644
--- a/internal/mode/static/state/store.go
+++ b/internal/mode/static/state/store.go
@@ -20,7 +20,7 @@ type Updater interface {
 
 // objectStore is a store of client.Object
 type objectStore interface {
-	get(nsname types.NamespacedName) (client.Object, bool)
+	get(nsname types.NamespacedName) client.Object
 	upsert(obj client.Object)
 	delete(nsname types.NamespacedName)
 }
@@ -37,9 +37,13 @@ func newObjectStoreMapAdapter[T client.Object](objects map[types.NamespacedName]
 	}
 }
 
-func (m *objectStoreMapAdapter[T]) get(nsname types.NamespacedName) (client.Object, bool) {
+func (m *objectStoreMapAdapter[T]) get(nsname types.NamespacedName) client.Object {
 	obj, exist := m.objects[nsname]
-	return obj, exist
+	if !exist {
+		return nil
+	}
+
+	return obj
 }
 
 func (m *objectStoreMapAdapter[T]) upsert(obj client.Object) {
@@ -92,7 +96,7 @@ func (m *multiObjectStore) mustFindStoreForObj(obj client.Object) objectStore {
 	return store
 }
 
-func (m *multiObjectStore) get(objType client.Object, nsname types.NamespacedName) (client.Object, bool) {
+func (m *multiObjectStore) get(objType client.Object, nsname types.NamespacedName) client.Object {
 	return m.mustFindStoreForObj(objType).get(nsname)
 }
 
@@ -107,58 +111,50 @@ func (m *multiObjectStore) delete(objType client.Object, nsname types.Namespaced
 type changeTrackingUpdaterObjectTypeCfg struct {
 	// store holds the objects of the gvk. If the store is nil, the objects of the gvk are not persisted.
 	store objectStore
-	gvk   schema.GroupVersionKind
-	// trackUpsertDelete indicates whether an upsert or delete of an object with the gvk results into a change to
-	// the changeTrackingUpdater's store. Note that for an upsert, the generation of a new object must be different
-	// from the generation of the previous version, otherwise such an upsert is not considered a change.
-	trackUpsertDelete bool
+	// predicate determines if upsert or delete event should trigger a change.
+	// If predicate is nil, then no upsert or delete event for this object will trigger a change.
+	predicate stateChangedPredicate
+	gvk       schema.GroupVersionKind
 }
 
-// triggerStateChangeFunc triggers a change to the changeTrackingUpdater's store for the given object.
-type triggerStateChangeFunc func(objType client.Object, nsname types.NamespacedName) bool
-
 // changeTrackingUpdater is an Updater that tracks changes to the cluster state in the multiObjectStore.
 //
 // It only works with objects with the GVKs registered in changeTrackingUpdaterObjectTypeCfg. Otherwise, it panics.
 //
 // A change is tracked when:
-// - An object with a GVK with a non-nil store and trackUpsertDelete set to 'true' is upserted or deleted, provided
-// that its generation changed.
-// - An object is upserted or deleted, and it is related to another object, based on the decision by
-// the relationship capturer.
-// - An object is upserted or deleted and triggerStateChange returns true for the object.
+// - An object with a GVK with a non-nil store and the stateChangedPredicate for that object returns true.
+// - An object is upserted or deleted, and it is related to another object,
+// based on the decision by the relationship capturer.
 type changeTrackingUpdater struct {
-	store              *multiObjectStore
-	capturer           relationship.Capturer
-	triggerStateChange triggerStateChangeFunc
+	store                  *multiObjectStore
+	capturer               relationship.Capturer
+	stateChangedPredicates map[schema.GroupVersionKind]stateChangedPredicate
 
-	extractGVK              extractGVKFunc
-	supportedGVKs           gvkList
-	trackedUpsertDeleteGVKs gvkList
-	persistedGVKs           gvkList
+	extractGVK    extractGVKFunc
+	supportedGVKs gvkList
+	persistedGVKs gvkList
 
 	changed bool
 }
 
 func newChangeTrackingUpdater(
 	capturer relationship.Capturer,
-	triggerStateChange triggerStateChangeFunc,
 	extractGVK extractGVKFunc,
 	objectTypeCfgs []changeTrackingUpdaterObjectTypeCfg,
 ) *changeTrackingUpdater {
 	var (
-		supportedGVKs           gvkList
-		trackedUpsertDeleteGVKs gvkList
-		persistedGVKs           gvkList
+		supportedGVKs gvkList
+		persistedGVKs gvkList
 
-		stores = make(map[schema.GroupVersionKind]objectStore)
+		stores                 = make(map[schema.GroupVersionKind]objectStore)
+		stateChangedPredicates = make(map[schema.GroupVersionKind]stateChangedPredicate)
 	)
 
 	for _, cfg := range objectTypeCfgs {
 		supportedGVKs = append(supportedGVKs, cfg.gvk)
 
-		if cfg.trackUpsertDelete {
-			trackedUpsertDeleteGVKs = append(trackedUpsertDeleteGVKs, cfg.gvk)
+		if cfg.predicate != nil {
+			stateChangedPredicates[cfg.gvk] = cfg.predicate
 		}
 
 		if cfg.store != nil {
@@ -168,13 +164,12 @@ func newChangeTrackingUpdater(
 	}
 
 	return &changeTrackingUpdater{
-		store:                   newMultiObjectStore(stores, extractGVK),
-		extractGVK:              extractGVK,
-		supportedGVKs:           supportedGVKs,
-		trackedUpsertDeleteGVKs: trackedUpsertDeleteGVKs,
-		persistedGVKs:           persistedGVKs,
-		capturer:                capturer,
-		triggerStateChange:      triggerStateChange,
+		store:                  newMultiObjectStore(stores, extractGVK),
+		extractGVK:             extractGVK,
+		supportedGVKs:          supportedGVKs,
+		persistedGVKs:          persistedGVKs,
+		capturer:               capturer,
+		stateChangedPredicates: stateChangedPredicates,
 	}
 }
 
@@ -185,18 +180,22 @@ func (s *changeTrackingUpdater) assertSupportedGVK(gvk schema.GroupVersionKind)
 }
 
 func (s *changeTrackingUpdater) upsert(obj client.Object) (changed bool) {
-	if !s.persistedGVKs.contains(s.extractGVK(obj)) {
+	objTypeGVK := s.extractGVK(obj)
+
+	if !s.persistedGVKs.contains(objTypeGVK) {
 		return false
 	}
 
-	oldObj, exist := s.store.get(obj, client.ObjectKeyFromObject(obj))
+	oldObj := s.store.get(obj, client.ObjectKeyFromObject(obj))
+
 	s.store.upsert(obj)
 
-	if !s.trackedUpsertDeleteGVKs.contains(s.extractGVK(obj)) {
+	stateChanged, ok := s.stateChangedPredicates[objTypeGVK]
+	if !ok {
 		return false
 	}
 
-	return !exist || obj.GetGeneration() != oldObj.GetGeneration()
+	return stateChanged.upsert(oldObj, obj)
 }
 
 func (s *changeTrackingUpdater) Upsert(obj client.Object) {
@@ -209,14 +208,12 @@ func (s *changeTrackingUpdater) Upsert(obj client.Object) {
 
 	relationshipExists := s.capturer.Exists(obj, client.ObjectKeyFromObject(obj))
 
-	forceChanged := s.triggerStateChange(obj, client.ObjectKeyFromObject(obj))
-
 	// FIXME(pleshakov): Check generation in all cases to minimize the number of Graph regeneration.
 	// s.changed can be true even if the generation of the object did not change, because
 	// capturer and triggerStateChange don't take the generation into account.
 	// See https://github.com/nginxinc/nginx-gateway-fabric/issues/825
 
-	s.changed = s.changed || changingUpsert || relationshipExisted || relationshipExists || forceChanged
+	s.changed = s.changed || changingUpsert || relationshipExisted || relationshipExists
 }
 
 func (s *changeTrackingUpdater) delete(objType client.Object, nsname types.NamespacedName) (changed bool) {
@@ -226,13 +223,19 @@ func (s *changeTrackingUpdater) delete(objType client.Object, nsname types.Names
 		return false
 	}
 
-	_, exist := s.store.get(objType, nsname)
-	if !exist {
+	obj := s.store.get(objType, nsname)
+	if obj == nil {
 		return false
 	}
+
 	s.store.delete(objType, nsname)
 
-	return s.trackedUpsertDeleteGVKs.contains(objTypeGVK)
+	stateChanged, ok := s.stateChangedPredicates[objTypeGVK]
+	if !ok {
+		return false
+	}
+
+	return stateChanged.delete(obj)
 }
 
 func (s *changeTrackingUpdater) Delete(objType client.Object, nsname types.NamespacedName) {
@@ -240,9 +243,7 @@ func (s *changeTrackingUpdater) Delete(objType client.Object, nsname types.Names
 
 	changingDelete := s.delete(objType, nsname)
 
-	forceChanged := s.triggerStateChange(objType, nsname)
-
-	s.changed = s.changed || changingDelete || s.capturer.Exists(objType, nsname) || forceChanged
+	s.changed = s.changed || changingDelete || s.capturer.Exists(objType, nsname)
 
 	s.capturer.Remove(objType, nsname)
 }
diff --git a/site/content/overview/gateway-api-compatibility.md b/site/content/overview/gateway-api-compatibility.md
index c748ab37fb..a7e983d839 100644
--- a/site/content/overview/gateway-api-compatibility.md
+++ b/site/content/overview/gateway-api-compatibility.md
@@ -66,8 +66,11 @@ Fields:
   - `conditions` - supported (Condition/Status/Reason):
     - `Accepted/True/Accepted`
     - `Accepted/False/InvalidParameters`
+    - `Accepted/False/UnsupportedVersion`
     - `Accepted/False/GatewayClassConflict`: Custom reason for when the GatewayClass references this controller, but
           a different GatewayClass name is provided to the controller via the command-line argument.
+    - `SupportedVersion/True/SupportedVersion`
+    - `SupportedVersion/False/UnsupportedVersion`
 
 ### Gateway