You need to cast the BOX3D back to a GEOMETRY for ST_AsGeoJSON to make sense of it:
SELECT JSONB_BUILD_OBJECT(
'type', 'FeatureCollection',
'features', JSONB_AGG(ST_AsGeoJSON(q.*)::JSONB)
) AS fc
FROM (
SELECT 1 AS id,
ST_Extent(ST_Expand('POINT(0 0)'::GEOMETRY, 1))::GEOMETRY AS geom
) q
;
Note that ST_Extent is an aggregate function, so the sub-select would need to be properly grouped to enclose anything but all geometries that you select! It may be easier to use ST_Envelope on individual geometries from the sub-select; it also returns GEOMETRY directly:
SELECT JSONB_BUILD_OBJECT(
'type', 'FeatureCollection',
'features', JSONB_AGG(ST_AsGeoJSON(q.*)::JSONB)
) AS fc
FROM (
SELECT 1 AS id,
ST_Envelope(ST_Expand('POINT(0 0)'::GEOMETRY, 1)) AS geom
) q
;
If a per-feature BBOX is needed, the non-RECORD signatures will include them as OGC standard into the GeoJSON; extending the example from @Encomiums (deleted) answer, this
SELECT JSONB_BUILD_OBJECT(
'type', 'FeatureCollection',
'features', JSONB_AGG(feature)
) AS fc
FROM (
SELECT JSONB_BUILD_OBJECT(
'type', 'Feature',
'id', id,
'geometry', ST_AsGeoJSON(geom, options => 1)::JSONB,
'properties', TO_JSONB(r.*) - 'geom' - 'id'
) AS feature
FROM (
SELECT 1 AS id,
'foo' AS name,
ST_Envelope(ST_Expand('POINT(0 0)'::GEOMETRY, 1)) AS geom
) r
) q
;
would add a bbox member to each feature.
I wrote a set of custom aggregate functions a while back that directly returns valid FeatureCollections; while they center around the RECORD signature of ST_AsGeoJSON (not including a bbox member), they make the first examples a bit more convenient:
SELECT ST_AsFeatureCollection(q.*) AS geojson
FROM (
SELECT 1 AS id,
ST_Envelope(ST_Expand('POINT(0 0)'::GEOMETRY, 1)) AS geom
) q
;