Repository: stac-utils/stac-fastapi (language: python)

Issue:
support custom data models
Currently there is no good way to use a different data model than what is defined in `models/database.py`.  Currently this is difficult to change in a way that is sustainable long term, should be much easier once #57 is resolved.
pgstac client can return prev: token in next link
[This logic](https://github.com/stac-utils/stac-fastapi/blob/master/stac_fastapi/pgstac/stac_fastapi/pgstac/models/links.py#L145-L146) set the request body directly into the link dict, which can later be modified. This happens if a next and prev link are both present; the last caller ends up modifying the token for both (resulting in an infinite loop when trying to crawl next links).

This should instead copy the response body out, e.g.

```python
   return {
        "rel": Relations.next,
        "type": MimeTypes.json,
        "method": method,
        "href": f"{self.request.url}",
        "body": {
            **self.request.postbody,
            "token": f"next:{self.next}"
        }
    }
```


Discussion / hints:
Thanks @geospatial-jeff for addressing this issue, it will have a great impact on the way we could extend the implementation with custom schemas. I was wondering if this issue is currently in your short term pipeline or not. There are some attributes which we would like to add to items via custom schemas and want to make sure that these attributes could be queryable. Also, I was wondering if it could be possible to search in the `properties` JSONB document for custom property, or it absolutely needs to be indexed in a different column (https://github.com/arturo-ai/arturo-stac-api/pull/70) for search purposes. 
Several of us jumped on a call to discuss this issue and this is what we came up with: https://github.com/stac-utils/stac-fastapi/wiki/Custom-Models

The short of it is to remove pydantic as the interface between the API layer and the backend clients.
No disagreement there; think it's a valid conclusion. Just wish Pydantic were flexible enough to handle this use case.


Fix patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index acab99582..8515d91e2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -7,7 +7,7 @@ Issues and pull requests are more than welcome.
 ```bash
 $ git clone https://github.com/stac-utils/stac-fastapi.git
 $ cd stac-fastapi
-$ pip install -e .[dev]
+$ pip install -e stac_fastapi/api[dev]
 ```
 
 **Python3.8 only**
@@ -23,7 +23,7 @@ $ pre-commit install
 ```bash
 $ git clone https://github.com/stac-utils/stac-fastapi.git
 $ cd stac-fastapi
-$ pip install -e .["docs"]
+$ pip install -e stac_fastapi/api["docs"]
 ```
 
 Hot-reloading docs:
diff --git a/docker-compose.yml b/docker-compose.yml
index 17ac9d640..94ea9a722 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -69,7 +69,7 @@ services:
 
   database:
     container_name: stac-db
-    image: bitner/pgstac:0.2.7
+    image: ghcr.io/stac-utils/pgstac:v0.2.8
     environment:
       - POSTGRES_USER=username
       - POSTGRES_PASSWORD=password
@@ -110,14 +110,14 @@ services:
     volumes:
       - ./stac_fastapi:/app/stac_fastapi
       - ./scripts:/app/scripts
-    command: 
+    command:
       - "./scripts/wait-for-it.sh"
       - "app-pgstac:8082"
       - "--"
       - "python"
       - "/app/scripts/ingest_joplin.py"
       - "http://app-pgstac:8082"
-    depends_on: 
+    depends_on:
       - database
       - app-pgstac
 
diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py
index 2c8ede0dd..ffb907b91 100644
--- a/stac_fastapi/api/setup.py
+++ b/stac_fastapi/api/setup.py
@@ -9,7 +9,7 @@
     "fastapi",
     "attrs",
     "pydantic[dotenv]",
-    "stac_pydantic==2.0.0",
+    "stac_pydantic==2.0.*",
 ]
 
 extra_reqs = {
@@ -19,6 +19,7 @@
         "pytest-asyncio",
         "pre-commit",
         "requests",
+        "pystac[validation]==1.*",
     ],
     "docs": ["mkdocs", "mkdocs-material", "pdocs"],
 }
diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py
index b59978e3a..919d66813 100644
--- a/stac_fastapi/api/stac_fastapi/api/app.py
+++ b/stac_fastapi/api/stac_fastapi/api/app.py
@@ -1,17 +1,20 @@
 """fastapi app creation."""
-from typing import Any, Dict, List, Optional, Type
+from typing import Any, Callable, Dict, List, Optional, Type, Union
 
 import attr
 from brotli_asgi import BrotliMiddleware
 from fastapi import APIRouter, FastAPI
 from fastapi.openapi.utils import get_openapi
+from pydantic import BaseModel
 from stac_pydantic import Collection, Item, ItemCollection
-from stac_pydantic.api import ConformanceClasses, LandingPage
+from stac_pydantic.api import ConformanceClasses, LandingPage, Search
 from stac_pydantic.api.collections import Collections
 from stac_pydantic.version import STAC_VERSION
+from starlette.responses import JSONResponse, Response
 
 from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers
 from stac_fastapi.api.models import (
+    APIRequest,
     CollectionUri,
     EmptyRequest,
     ItemCollectionUri,
@@ -19,12 +22,12 @@
     SearchGetRequest,
     _create_request_model,
 )
-from stac_fastapi.api.routes import create_endpoint
+from stac_fastapi.api.routes import create_async_endpoint, create_sync_endpoint
 
 # TODO: make this module not depend on `stac_fastapi.extensions`
 from stac_fastapi.extensions.core import FieldsExtension
 from stac_fastapi.types.config import ApiSettings, Settings
-from stac_fastapi.types.core import BaseCoreClient
+from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient
 from stac_fastapi.types.extension import ApiExtension
 from stac_fastapi.types.search import STACSearch
 
@@ -54,17 +57,19 @@ class StacApi:
     """
 
     settings: ApiSettings = attr.ib()
-    client: BaseCoreClient = attr.ib()
+    client: Union[AsyncBaseCoreClient, BaseCoreClient] = attr.ib()
     extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list))
     exceptions: Dict[Type[Exception], int] = attr.ib(
         default=attr.Factory(lambda: DEFAULT_STATUS_CODES)
     )
     app: FastAPI = attr.ib(default=attr.Factory(FastAPI))
+    router: APIRouter = attr.ib(default=attr.Factory(APIRouter))
     title: str = attr.ib(default="stac-fastapi")
     api_version: str = attr.ib(default="0.1")
     stac_version: str = attr.ib(default=STAC_VERSION)
     description: str = attr.ib(default="stac-fastapi")
-    search_request_model = attr.ib(default=STACSearch)
+    search_request_model: Type[Search] = attr.ib(default=STACSearch)
+    response_class: Type[Response] = attr.ib(default=JSONResponse)
 
     def get_extension(self, extension: Type[ApiExtension]) -> Optional[ApiExtension]:
         """Get an extension.
@@ -80,100 +85,200 @@ def get_extension(self, extension: Type[ApiExtension]) -> Optional[ApiExtension]
                 return ext
         return None
 
-    def register_core(self):
-        """Register core STAC endpoints.
-
-            GET /
-            GET /conformance
-            GET /collections
-            GET /collections/{collectionId}
-            GET /collections/{collectionId}/items
-            GET /collection/{collectionId}/items/{itemId}
-            GET /search
-            POST /search
-
-        Injects application logic (StacApi.client) into the API layer.
+    def _create_endpoint(
+        self, func: Callable, request_type: Union[Type[APIRequest], Type[BaseModel]]
+    ) -> Callable:
+        """Create a FastAPI endpoint."""
+        if isinstance(self.client, AsyncBaseCoreClient):
+            return create_async_endpoint(
+                func, request_type, response_class=self.response_class
+            )
+        elif isinstance(self.client, BaseCoreClient):
+            return create_sync_endpoint(
+                func, request_type, response_class=self.response_class
+            )
+        raise NotImplementedError
+
+    def register_landing_page(self):
+        """Register landing page (GET /).
 
         Returns:
             None
         """
-        search_request_model = _create_request_model(self.search_request_model)
-
-        fields_ext = self.get_extension(FieldsExtension)
-        router = APIRouter()
-        router.add_api_route(
+        self.router.add_api_route(
             name="Landing Page",
             path="/",
-            response_model=LandingPage,
+            response_model=LandingPage
+            if self.settings.enable_response_models
+            else None,
+            response_class=self.response_class,
             response_model_exclude_unset=False,
             response_model_exclude_none=True,
             methods=["GET"],
-            endpoint=create_endpoint(self.client.landing_page, EmptyRequest),
+            endpoint=self._create_endpoint(self.client.landing_page, EmptyRequest),
         )
-        router.add_api_route(
+
+    def register_conformance_classes(self):
+        """Register conformance classes (GET /conformance).
+
+        Returns:
+            None
+        """
+        self.router.add_api_route(
             name="Conformance Classes",
             path="/conformance",
-            response_model=ConformanceClasses,
+            response_model=ConformanceClasses
+            if self.settings.enable_response_models
+            else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["GET"],
-            endpoint=create_endpoint(self.client.conformance, EmptyRequest),
+            endpoint=self._create_endpoint(self.client.conformance, EmptyRequest),
         )
-        router.add_api_route(
+
+    def register_get_item(self):
+        """Register get item endpoint (GET /collections/{collectionId}/items/{itemId}).
+
+        Returns:
+            None
+        """
+        self.router.add_api_route(
             name="Get Item",
             path="/collections/{collectionId}/items/{itemId}",
-            response_model=Item,
+            response_model=Item if self.settings.enable_response_models else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["GET"],
-            endpoint=create_endpoint(self.client.get_item, ItemUri),
+            endpoint=self._create_endpoint(self.client.get_item, ItemUri),
         )
-        router.add_api_route(
+
+    def register_post_search(self):
+        """Register search endpoint (POST /search).
+
+        Returns:
+            None
+        """
+        search_request_model = _create_request_model(self.search_request_model)
+        fields_ext = self.get_extension(FieldsExtension)
+        self.router.add_api_route(
             name="Search",
             path="/search",
-            response_model=ItemCollection if not fields_ext else None,
+            response_model=(ItemCollection if not fields_ext else None)
+            if self.settings.enable_response_models
+            else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["POST"],
-            endpoint=create_endpoint(self.client.post_search, search_request_model),
-        ),
-        router.add_api_route(
+            endpoint=self._create_endpoint(
+                self.client.post_search, search_request_model
+            ),
+        )
+
+    def register_get_search(self):
+        """Register search endpoint (GET /search).
+
+        Returns:
+            None
+        """
+        fields_ext = self.get_extension(FieldsExtension)
+        self.router.add_api_route(
             name="Search",
             path="/search",
-            response_model=ItemCollection if not fields_ext else None,
+            response_model=(ItemCollection if not fields_ext else None)
+            if self.settings.enable_response_models
+            else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["GET"],
-            endpoint=create_endpoint(self.client.get_search, SearchGetRequest),
+            endpoint=self._create_endpoint(self.client.get_search, SearchGetRequest),
         )
-        router.add_api_route(
+
+    def register_get_collections(self):
+        """Register get collections endpoint (GET /collections).
+
+        Returns:
+            None
+        """
+        self.router.add_api_route(
             name="Get Collections",
             path="/collections",
-            response_model=Collections,
+            response_model=Collections
+            if self.settings.enable_response_models
+            else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["GET"],
-            endpoint=create_endpoint(self.client.all_collections, EmptyRequest),
+            endpoint=self._create_endpoint(self.client.all_collections, EmptyRequest),
         )
-        router.add_api_route(
+
+    def register_get_collection(self):
+        """Register get collection endpoint (GET /collection/{collectionId}).
+
+        Returns:
+            None
+        """
+        self.router.add_api_route(
             name="Get Collection",
             path="/collections/{collectionId}",
-            response_model=Collection,
+            response_model=Collection if self.settings.enable_response_models else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["GET"],
-            endpoint=create_endpoint(self.client.get_collection, CollectionUri),
+            endpoint=self._create_endpoint(self.client.get_collection, CollectionUri),
         )
-        router.add_api_route(
+
+    def register_get_item_collection(self):
+        """Register get item collection endpoint (GET /collection/{collectionId}/items).
+
+        Returns:
+            None
+        """
+        self.router.add_api_route(
             name="Get ItemCollection",
             path="/collections/{collectionId}/items",
-            response_model=ItemCollection,
+            response_model=ItemCollection
+            if self.settings.enable_response_models
+            else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["GET"],
-            endpoint=create_endpoint(self.client.item_collection, ItemCollectionUri),
+            endpoint=self._create_endpoint(
+                self.client.item_collection, ItemCollectionUri
+            ),
         )
-        self.app.include_router(router)
+
+    def register_core(self):
+        """Register core STAC endpoints.
+
+            GET /
+            GET /conformance
+            GET /collections
+            GET /collections/{collectionId}
+            GET /collections/{collectionId}/items
+            GET /collection/{collectionId}/items/{itemId}
+            GET /search
+            POST /search
+
+        Injects application logic (StacApi.client) into the API layer.
+
+        Returns:
+            None
+        """
+        self.register_landing_page()
+        self.register_conformance_classes()
+        self.register_get_item()
+        self.register_post_search()
+        self.register_get_search()
+        self.register_get_collections()
+        self.register_get_collection()
+        self.register_get_item_collection()
 
     def customize_openapi(self) -> Optional[Dict[str, Any]]:
         """Customize openapi schema."""
@@ -219,7 +324,10 @@ def __attrs_post_init__(self):
         Settings.set(self.settings)
         self.app.state.settings = self.settings
 
+        # Register core STAC endpoints
         self.register_core()
+        self.app.include_router(self.router)
+
         # register extensions
         for ext in self.extensions:
             ext.register(self.app)
diff --git a/stac_fastapi/api/stac_fastapi/api/openapi.py b/stac_fastapi/api/stac_fastapi/api/openapi.py
index 3eec0c1bc..adc753e3f 100644
--- a/stac_fastapi/api/stac_fastapi/api/openapi.py
+++ b/stac_fastapi/api/stac_fastapi/api/openapi.py
@@ -6,6 +6,8 @@
 from stac_fastapi.types.config import ApiSettings
 
 
+# TODO: Remove or fix, this is currently unused
+# and calls a missing method on ApiSettings
 def config_openapi(app: FastAPI, settings: ApiSettings):
     """Config openapi."""
 
diff --git a/stac_fastapi/api/stac_fastapi/api/routes.py b/stac_fastapi/api/stac_fastapi/api/routes.py
index b01aa3f1a..aa3911ede 100644
--- a/stac_fastapi/api/stac_fastapi/api/routes.py
+++ b/stac_fastapi/api/stac_fastapi/api/routes.py
@@ -1,83 +1,96 @@
 """route factories."""
-import inspect
-from typing import Callable, Type, Union
+from typing import Any, Callable, Dict, Type, Union
 
 from fastapi import Depends
 from pydantic import BaseModel
 from starlette.requests import Request
+from starlette.responses import JSONResponse, Response
 
 from stac_fastapi.api.models import APIRequest
 
 
-def create_endpoint(
-    func: Callable, request_model: Union[Type[APIRequest], Type[BaseModel]]
-) -> Callable:
-    """
-    Create a FastAPI endpoint.
-
-    This endpoint executes code in an external threadpool which is then awaited by the event loop.
-    https://fastapi.tiangolo.com/async/#path-operation-functions
-
-    Accepts either an `stac_fastapi.api.models.APIRequest` or `pydantic.BaseModel`.
-
-    Pydantic models are good for validating request bodies (ex. POST requests), and expect the signature of the
-    callable matches that of the request model.
-
-    APIRequest is good for validating query/path parameters (ex. GET requests) and allows for FastAPI dependency
-    injection.  It is expected the that the return of `APIRequest.kwargs` matches that of the callable.
-
-    Args:
-        func: the wrapped function.
-        request_model: either `stac_fastapi.api.models.APIRequest` or `pydantic.BaseModel`.
+def _wrap_response(resp: Any, response_class: Type[Response]) -> Response:
+    if isinstance(resp, Response):
+        return resp
+    else:
+        return response_class(resp)
+
+
+def create_async_endpoint(
+    func: Callable,
+    request_model: Union[Type[APIRequest], Type[BaseModel], Dict],
+    response_class: Type[Response] = JSONResponse,
+):
+    """Wrap a coroutine in another coroutine which may be used to create a FastAPI endpoint."""
+    if issubclass(request_model, APIRequest):
+
+        async def _endpoint(
+            request: Request,
+            request_data: request_model = Depends(),  # type:ignore
+        ):
+            """Endpoint."""
+            return _wrap_response(
+                await func(request=request, **request_data.kwargs()), response_class
+            )
+
+    elif issubclass(request_model, BaseModel):
+
+        async def _endpoint(
+            request: Request,
+            request_data: request_model,  # type:ignore
+        ):
+            """Endpoint."""
+            return _wrap_response(
+                await func(request_data, request=request), response_class
+            )
 
-    Returns:
-        callable: fastapi route which may be added to a router/application
-    """
-    if not inspect.iscoroutinefunction(func):
-        if issubclass(request_model, APIRequest):
+    else:
 
-            def _endpoint(
-                request: Request,
-                request_data: request_model = Depends(),  # type:ignore
-            ):
-                """Endpoint."""
-                resp = func(
-                    request=request, **request_data.kwargs()  # type:ignore
-                )
-                return resp
+        async def _endpoint(
+            request: Request,
+            request_data: Dict[str, Any],  # type:ignore
+        ):
+            """Endpoint."""
+            return _wrap_response(
+                await func(request_data, request=request), response_class
+            )
+
+    return _endpoint
+
+
+def create_sync_endpoint(
+    func: Callable,
+    request_model: Union[Type[APIRequest], Type[BaseModel], Dict],
+    response_class: Type[Response] = JSONResponse,
+):
+    """Wrap a function in another function which may be used to create a FastAPI endpoint."""
+    if issubclass(request_model, APIRequest):
+
+        def _endpoint(
+            request: Request,
+            request_data: request_model = Depends(),  # type:ignore
+        ):
+            """Endpoint."""
+            return _wrap_response(
+                func(request=request, **request_data.kwargs()), response_class
+            )
+
+    elif issubclass(request_model, BaseModel):
+
+        def _endpoint(
+            request: Request,
+            request_data: request_model,  # type:ignore
+        ):
+            """Endpoint."""
+            return _wrap_response(func(request_data, request=request), response_class)
 
-        else:
+    else:
 
-            def _endpoint(
-                request: Request,
-                request_data: request_model,  # type:ignore
-            ):
-                """Endpoint."""
-                resp = func(request_data, request=request)
-                return resp
+        def _endpoint(
+            request: Request,
+            request_data: Dict[str, Any],  # type:ignore
+        ):
+            """Endpoint."""
+            return _wrap_response(func(request_data, request=request), response_class)
 
-        return _endpoint
-    else:
-        if issubclass(request_model, APIRequest):
-
-            async def _endpoint(
-                request: Request,
-                request_data: request_model = Depends(),  # type:ignore
-            ):
-                """Endpoint."""
-                resp = await func(
-                    request=request, **request_data.kwargs()  # type:ignore
-                )
-                return resp
-
-        else:
-
-            async def _endpoint(
-                request: Request,
-                request_data: request_model,  # type:ignore
-            ):
-                """Endpoint."""
-                resp = await func(request_data, request=request)
-                return resp
-
-        return _endpoint
+    return _endpoint
diff --git a/stac_fastapi/extensions/setup.py b/stac_fastapi/extensions/setup.py
index 0ae789680..315e1d94a 100644
--- a/stac_fastapi/extensions/setup.py
+++ b/stac_fastapi/extensions/setup.py
@@ -9,7 +9,7 @@
     "fastapi",
     "attrs",
     "pydantic[dotenv]",
-    "stac_pydantic==2.0.0",
+    "stac_pydantic==2.0.*",
     "stac-fastapi.types",
     "stac-fastapi.api",
 ]
diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py
index f4d580d18..a46d25641 100644
--- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py
+++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py
@@ -1,11 +1,17 @@
 """transaction extension."""
+from typing import Callable, Type, Union
+
 import attr
 from fastapi import APIRouter, FastAPI
+from pydantic import BaseModel
 from stac_pydantic import Collection, Item
+from starlette.responses import JSONResponse, Response
 
-from stac_fastapi.api.models import CollectionUri, ItemUri, _create_request_model
-from stac_fastapi.api.routes import create_endpoint
-from stac_fastapi.types.core import BaseTransactionsClient
+from stac_fastapi.api.models import APIRequest, CollectionUri, ItemUri
+from stac_fastapi.api.routes import create_async_endpoint, create_sync_endpoint
+from stac_fastapi.types import stac as stac_types
+from stac_fastapi.types.config import ApiSettings
+from stac_fastapi.types.core import AsyncBaseTransactionsClient, BaseTransactionsClient
 from stac_fastapi.types.extension import ApiExtension
 
 
@@ -28,77 +34,129 @@ class TransactionExtension(ApiExtension):
         client: CRUD application logic
     """
 
-    client: BaseTransactionsClient = attr.ib()
+    client: Union[AsyncBaseTransactionsClient, BaseTransactionsClient] = attr.ib()
+    settings: ApiSettings = attr.ib()
+    router: APIRouter = attr.ib(factory=APIRouter)
+    response_class: Type[Response] = attr.ib(default=JSONResponse)
 
-    def register(self, app: FastAPI) -> None:
-        """Register the extension with a FastAPI application.
+    def _create_endpoint(
+        self,
+        func: Callable,
+        request_type: Union[
+            Type[APIRequest],
+            Type[BaseModel],
+            Type[stac_types.Item],
+            Type[stac_types.Collection],
+        ],
+    ) -> Callable:
+        """Create a FastAPI endpoint."""
+        if isinstance(self.client, AsyncBaseTransactionsClient):
+            return create_async_endpoint(
+                func, request_type, response_class=self.response_class
+            )
+        elif isinstance(self.client, BaseTransactionsClient):
+            return create_sync_endpoint(
+                func, request_type, response_class=self.response_class
+            )
+        raise NotImplementedError
 
-        Args:
-            app: target FastAPI application.
-
-        Returns:
-            None
-        """
-        item_request_model = _create_request_model(Item)
-        collection_request_model = _create_request_model(Collection)
-
-        router = APIRouter()
-        router.add_api_route(
+    def register_create_item(self):
+        """Register create item endpoint (POST /collections/{collectionId}/items)."""
+        self.router.add_api_route(
             name="Create Item",
             path="/collections/{collectionId}/items",
-            response_model=Item,
+            response_model=Item if self.settings.enable_response_models else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["POST"],
-            endpoint=create_endpoint(self.client.create_item, item_request_model),
+            endpoint=self._create_endpoint(self.client.create_item, stac_types.Item),
         )
-        router.add_api_route(
+
+    def register_update_item(self):
+        """Register update item endpoint (PUT /collections/{collectionId}/items)."""
+        self.router.add_api_route(
             name="Update Item",
             path="/collections/{collectionId}/items",
-            response_model=Item,
+            response_model=Item if self.settings.enable_response_models else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["PUT"],
-            endpoint=create_endpoint(self.client.update_item, item_request_model),
+            endpoint=self._create_endpoint(self.client.update_item, stac_types.Item),
         )
-        router.add_api_route(
+
+    def register_delete_item(self):
+        """Register delete item endpoint (DELETE /collections/{collectionId}/items/{itemId})."""
+        self.router.add_api_route(
             name="Delete Item",
             path="/collections/{collectionId}/items/{itemId}",
-            response_model=Item,
+            response_model=Item if self.settings.enable_response_models else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["DELETE"],
-            endpoint=create_endpoint(self.client.delete_item, ItemUri),
+            endpoint=self._create_endpoint(self.client.delete_item, ItemUri),
         )
-        router.add_api_route(
+
+    def register_create_collection(self):
+        """Register create collection endpoint (POST /collections)."""
+        self.router.add_api_route(
             name="Create Collection",
             path="/collections",
-            response_model=Collection,
+            response_model=Collection if self.settings.enable_response_models else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["POST"],
-            endpoint=create_endpoint(
-                self.client.create_collection, collection_request_model
+            endpoint=self._create_endpoint(
+                self.client.create_collection, stac_types.Collection
             ),
         )
-        router.add_api_route(
+
+    def register_update_collection(self):
+        """Register update collection endpoint (PUT /collections)."""
+        self.router.add_api_route(
             name="Update Collection",
             path="/collections",
-            response_model=Collection,
+            response_model=Collection if self.settings.enable_response_models else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["PUT"],
-            endpoint=create_endpoint(
-                self.client.update_collection, collection_request_model
+            endpoint=self._create_endpoint(
+                self.client.update_collection, stac_types.Collection
             ),
         )
-        router.add_api_route(
+
+    def register_delete_collection(self):
+        """Register delete collection endpoint (DELETE /collections/{collectionId})."""
+        self.router.add_api_route(
             name="Delete Collection",
             path="/collections/{collectionId}",
-            response_model=Collection,
+            response_model=Collection if self.settings.enable_response_models else None,
+            response_class=self.response_class,
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["DELETE"],
-            endpoint=create_endpoint(self.client.delete_collection, CollectionUri),
+            endpoint=self._create_endpoint(
+                self.client.delete_collection, CollectionUri
+            ),
         )
-        app.include_router(router, tags=["Transaction Extension"])
+
+    def register(self, app: FastAPI) -> None:
+        """Register the extension with a FastAPI application.
+
+        Args:
+            app: target FastAPI application.
+
+        Returns:
+            None
+        """
+        self.register_create_item()
+        self.register_update_item()
+        self.register_delete_item()
+        self.register_create_collection()
+        self.register_update_collection()
+        self.register_delete_collection()
+        app.include_router(self.router, tags=["Transaction Extension"])
diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py
index 2edb2b446..d55e7f344 100644
--- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py
+++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py
@@ -1,21 +1,24 @@
 """bulk transactions extension."""
 import abc
-from typing import List, Optional
+from typing import Any, Dict, Optional
 
 import attr
 from fastapi import APIRouter, FastAPI
 from pydantic import BaseModel
-from stac_pydantic import Item
 
 from stac_fastapi.api.models import _create_request_model
-from stac_fastapi.api.routes import create_endpoint
+from stac_fastapi.api.routes import create_sync_endpoint
 from stac_fastapi.types.extension import ApiExtension
 
 
 class Items(BaseModel):
-    """Items model."""
+    """A list of STAC items."""
 
-    items: List[Item]
+    items: Dict[str, Any]
+
+    def __iter__(self):
+        """Return an iterable of STAC items."""
+        return iter(self.items)
 
 
 @attr.s  # type: ignore
@@ -77,6 +80,8 @@ def register(self, app: FastAPI) -> None:
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["POST"],
-            endpoint=create_endpoint(self.client.bulk_item_insert, items_request_model),
+            endpoint=create_sync_endpoint(
+                self.client.bulk_item_insert, items_request_model
+            ),
         )
         app.include_router(router, tags=["Bulk Transaction Extension"])
diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/tiles.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/tiles.py
index bf3030914..84c58ad4d 100644
--- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/tiles.py
+++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/tiles.py
@@ -1,6 +1,6 @@
 """tiles extension."""
 import abc
-from typing import List, Optional, Union
+from typing import Any, Dict, List, Optional, Union
 from urllib.parse import urljoin
 
 import attr
@@ -13,7 +13,7 @@
 from starlette.responses import HTMLResponse, RedirectResponse
 
 from stac_fastapi.api.models import ItemUri
-from stac_fastapi.api.routes import create_endpoint
+from stac_fastapi.api.routes import create_sync_endpoint
 from stac_fastapi.types.core import BaseCoreClient
 from stac_fastapi.types.extension import ApiExtension
 
@@ -137,15 +137,15 @@ class TilesClient(BaseTilesClient):
 
     def get_item_tiles(
         self, item_id: str, collection_id: str, **kwargs
-    ) -> Union[RedirectResponse, TileSetResource]:
+    ) -> Union[RedirectResponse, Dict[str, Any]]:
         """Get OGC TileSet resource for a stac item."""
         item = self.client.get_item(item_id, collection_id, **kwargs)
         resource = TileSetResource(
-            extent=SpatialExtent(bbox=[list(item.bbox)]),
-            title=f"Tiled layer of {item.collection}/{item.id}",
+            extent=SpatialExtent(bbox=[list(item["bbox"])]),
+            title=f"Tiled layer of {item['collection']}/{item['id']}",
             links=TileLinks(
-                item_id=item.id,
-                collection_id=item.collection,
+                item_id=item["id"],
+                collection_id=item["collection"],
                 base_url=str(kwargs["request"].base_url),
                 route_prefix=self.route_prefix,
             ).create_links(),
@@ -157,7 +157,7 @@ def get_item_tiles(
             ][0]
             return RedirectResponse(viewer_url)
 
-        return resource
+        return resource.dict(exclude_unset=True)
 
 
 @attr.s
@@ -208,6 +208,6 @@ def stac_demo(request: Request):
             response_model_exclude_none=True,
             response_model_exclude_unset=True,
             methods=["GET"],
-            endpoint=create_endpoint(self.client.get_item_tiles, ItemUri),
+            endpoint=create_sync_endpoint(self.client.get_item_tiles, ItemUri),
             tags=["OGC Tiles"],
         )
diff --git a/stac_fastapi/pgstac/setup.py b/stac_fastapi/pgstac/setup.py
index 6d7cfe3d8..fec0e67f0 100644
--- a/stac_fastapi/pgstac/setup.py
+++ b/stac_fastapi/pgstac/setup.py
@@ -10,7 +10,7 @@
     "attrs",
     "orjson",
     "pydantic[dotenv]",
-    "stac_pydantic==2.0.0",
+    "stac_pydantic==2.0.*",
     "stac-fastapi.types",
     "stac-fastapi.api",
     "stac-fastapi.extensions",
@@ -27,7 +27,7 @@
         "pytest-asyncio",
         "pre-commit",
         "requests",
-        "pypgstac==0.2.7",
+        "pypgstac==0.2.8",
         "httpx",
     ],
     "docs": ["mkdocs", "mkdocs-material", "pdocs"],
diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py
index 92f7db0f6..96ab724a0 100644
--- a/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py
+++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py
@@ -1,4 +1,6 @@
 """FastAPI application using PGStac."""
+from fastapi.responses import ORJSONResponse
+
 from stac_fastapi.api.app import StacApi
 from stac_fastapi.extensions.core import (
     FieldsExtension,
@@ -17,13 +19,18 @@
 api = StacApi(
     settings=settings,
     extensions=[
-        TransactionExtension(client=TransactionsClient),
+        TransactionExtension(
+            client=TransactionsClient(),
+            settings=settings,
+            response_class=ORJSONResponse,
+        ),
         QueryExtension(),
         SortExtension(),
         FieldsExtension(),
     ],
     client=CoreCrudClient(),
     search_request_model=PgstacSearch,
+    response_class=ORJSONResponse,
 )
 app = api.app
 
diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py
index 8bc0a43e8..eeb0f5535 100644
--- a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py
+++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py
@@ -2,104 +2,37 @@
 import re
 from datetime import datetime
 from typing import Any, Dict, List, Optional, Union
-from urllib.parse import urljoin
 
 import attr
 import orjson
 from buildpg import render
-from fastapi.responses import ORJSONResponse
-from stac_pydantic import Collection, Item, ItemCollection
-from stac_pydantic.api import ConformanceClasses, LandingPage
-from stac_pydantic.api.collections import Collections
-from stac_pydantic.links import Link, Relations
-from stac_pydantic.shared import MimeTypes
+from starlette.requests import Request
 
 from stac_fastapi.pgstac.models.links import CollectionLinks, ItemLinks, PagingLinks
 from stac_fastapi.pgstac.types.search import PgstacSearch
-from stac_fastapi.types.core import BaseCoreClient
+from stac_fastapi.types.core import AsyncBaseCoreClient
 from stac_fastapi.types.errors import NotFoundError
+from stac_fastapi.types.stac import Collection, Conformance, Item, ItemCollection
 
 NumType = Union[float, int]
 
 
 @attr.s
-class CoreCrudClient(BaseCoreClient):
+class CoreCrudClient(AsyncBaseCoreClient):
     """Client for core endpoints defined by stac."""
 
-    landing_page_id: str = attr.ib(default="stac-api")
-    title: str = attr.ib(default="Arturo STAC API")
-    description: str = attr.ib(default="Arturo raster datastore")
-    conformance_classes: List[str] = attr.ib(
-        factory=lambda: [
-            "https://stacspec.org/STAC-api.html",
-            "http://docs.opengeospatial.org/is/17-069r3/17-069r3.html#ats_geojson",
-        ]
-    )
-
-    async def landing_page(self, **kwargs) -> ORJSONResponse:
-        """Landing page.
-
-        Called with `GET /`.
-
-        Returns:
-            API landing page, serving as an entry point to the API.
-        """
-        request = kwargs["request"]
-        base_url = str(request.base_url)
-        landing_page = LandingPage(
-            id=self.landing_page_id,
-            title=self.title,
-            description=self.description,
-            conformsTo=self.conformance_classes,
-            links=[
-                Link(
-                    rel=Relations.self,
-                    type=MimeTypes.json,
-                    href=str(base_url),
-                ),
-                Link(
-                    rel=Relations.docs,
-                    type=MimeTypes.html,
-                    title="OpenAPI docs",
-                    href=urljoin(base_url, "docs"),
-                ),
-                Link(
-                    rel=Relations.conformance,
-                    type=MimeTypes.json,
-                    title="STAC/WFS3 conformance classes implemented by this server",
-                    href=urljoin(base_url, "conformance"),
-                ),
-                Link(
-                    rel=Relations.search,
-                    type=MimeTypes.geojson,
-                    title="STAC search",
-                    href=urljoin(base_url, "search"),
-                ),
-                Link(
-                    rel="data",
-                    type=MimeTypes.json,
-                    href=urljoin(base_url, "collections"),
-                ),
-            ],
-        )
-        collections = await self._all_collections_func(request=request)
-        if collections:
-            for coll in collections:
-                coll_link = CollectionLinks(
-                    collection_id=coll.id, request=request
-                ).link_self()
-                coll_link.rel = Relations.child
-                coll_link.title = coll.title
-                landing_page.links.append(coll_link)
-        return ORJSONResponse(landing_page.dict(exclude_none=True))
-
-    async def conformance(self, **kwargs) -> ConformanceClasses:
+    async def conformance(self, **kwargs) -> Conformance:
         """Conformance classes."""
-        return ConformanceClasses(conformsTo=self.conformance_classes)
+        return Conformance(
+            conformsTo=[
+                "https://stacspec.org/STAC-api.html",
+                "http://docs.opengeospatial.org/is/17-069r3/17-069r3.html#ats_geojson",
+            ]
+        )
 
-    async def _all_collections_func(self, **kwargs) -> List[Collection]:
+    async def all_collections(self, **kwargs) -> List[Collection]:
         """Read all collections from the database."""
-        request = kwargs["request"]
+        request: Request = kwargs["request"]
         pool = request.app.state.readpool
 
         async with pool.acquire() as conn:
@@ -108,39 +41,17 @@ async def _all_collections_func(self, **kwargs) -> List[Collection]:
                 SELECT * FROM all_collections();
                 """
             )
-        linked_collections = []
+        linked_collections: List[Collection] = []
         if collections is not None and len(collections) > 0:
             for c in collections:
-                coll = Collection.construct(**c)
-                coll.links = await CollectionLinks(
-                    collection_id=coll.id, request=request
+                coll = Collection(**c)
+                coll["links"] = await CollectionLinks(
+                    collection_id=coll["id"], request=request
                 ).get_links()
                 linked_collections.append(coll)
         return linked_collections
 
-    async def all_collections(self, **kwargs) -> ORJSONResponse:
-        """Get all collections."""
-        request = kwargs["request"]
-        base_url = str(request.base_url)
-        url = str(request.url)
-        collections = await self._all_collections_func(**kwargs)
-        links = [
-            Link(rel=Relations.self, type=MimeTypes.json, href=url),
-            Link(rel=Relations.parent, type=MimeTypes.json, href=base_url),
-            Link(rel=Relations.root, type=MimeTypes.json, href=base_url),
-        ]
-        if collections is None:
-            return ORJSONResponse(
-                Collections(collections=[], links=links).dict(exclude_none=True)
-            )
-        return ORJSONResponse(
-            Collections(
-                collections=[c.dict(exclude_none=True) for c in collections],
-                links=links,
-            ).dict(exclude_none=True)
-        )
-
-    async def get_collection(self, id: str, **kwargs) -> ORJSONResponse:
+    async def get_collection(self, id: str, **kwargs) -> Collection:
         """Get collection by id.
 
         Called with `GET /collections/{collectionId}`.
@@ -151,8 +62,10 @@ async def get_collection(self, id: str, **kwargs) -> ORJSONResponse:
         Returns:
             Collection.
         """
-        request = kwargs["request"]
-        pool = kwargs["request"].app.state.readpool
+        collection: Optional[Dict[str, Any]]
+
+        request: Request = kwargs["request"]
+        pool = request.app.state.readpool
         async with pool.acquire() as conn:
             q, p = render(
                 """
@@ -165,13 +78,11 @@ async def get_collection(self, id: str, **kwargs) -> ORJSONResponse:
             raise NotFoundError
         links = await CollectionLinks(collection_id=id, request=request).get_links()
         collection["links"] = links
-        return ORJSONResponse(
-            Collection.construct(**collection).dict(exclude_none=True)
-        )
+        return Collection(**collection)
 
     async def _search_base(
-        self, search_request: PgstacSearch, **kwargs
-    ) -> Dict[str, Any]:
+        self, search_request: PgstacSearch, **kwargs: Any
+    ) -> ItemCollection:
         """Cross catalog search (POST).
 
         Called with `POST /search`.
@@ -182,7 +93,9 @@ async def _search_base(
         Returns:
             ItemCollection containing items which match the search criteria.
         """
-        request = kwargs["request"]
+        items: Dict[str, Any]
+
+        request: Request = kwargs["request"]
         pool = request.app.state.readpool
 
         # pool = kwargs["request"].app.state.readpool
@@ -196,34 +109,37 @@ async def _search_base(
                 req=req,
             )
             items = await conn.fetchval(q, *p)
-        next = items.pop("next", None)
-        prev = items.pop("prev", None)
-        collection = ItemCollection.construct(**items)
-        cleaned_features = []
-        if collection.features is None or len(collection.features) == 0:
+        next: Optional[str] = items.pop("next", None)
+        prev: Optional[str] = items.pop("prev", None)
+        collection = ItemCollection(**items)
+        cleaned_features: List[Item] = []
+        if collection["features"] is None or len(collection["features"]) == 0:
             raise NotFoundError("No features found")
 
-        for feature in collection.features:
-            feature = Item.construct(**feature)
-            if "links" not in search_request.fields.exclude:
+        for feature in collection["features"]:
+            feature = Item(**feature)
+            if (
+                search_request.fields.exclude is None
+                or "links" not in search_request.fields.exclude
+            ):
+                # TODO: feature.collection is not always included
+                # This code fails if it's left outside of the fields expression
+                # I've fields extension updated test cases to always include feature.collection
                 links = await ItemLinks(
-                    collection_id=feature.collection,
-                    item_id=feature.id,
+                    collection_id=feature["collection"],
+                    item_id=feature["id"],
                     request=request,
                 ).get_links()
-                feature.links = links
+                feature["links"] = links
                 exclude = search_request.fields.exclude
-                if len(exclude) == 0:
+                if exclude and len(exclude) == 0:
                     exclude = None
                 include = search_request.fields.include
-                if len(include) == 0:
+                if include and len(include) == 0:
                     include = None
-                feature = feature.dict(
-                    exclude_none=True,
-                )
             cleaned_features.append(feature)
-            collection.features = cleaned_features
-        collection.links = await PagingLinks(
+            collection["features"] = cleaned_features
+        collection["links"] = await PagingLinks(
             request=request,
             next=next,
             prev=prev,
@@ -232,7 +148,7 @@ async def _search_base(
 
     async def item_collection(
         self, id: str, limit: int = 10, token: str = None, **kwargs
-    ) -> ORJSONResponse:
+    ) -> ItemCollection:
         """Get all items from a specific collection.
 
         Called with `GET /collections/{collectionId}/items`
@@ -249,13 +165,11 @@ async def item_collection(
         collection = await self._search_base(req, **kwargs)
         links = await CollectionLinks(
             collection_id=id, request=kwargs["request"]
-        ).get_links(extra_links=collection.links)
-        collection.links = links
-        return ORJSONResponse(collection.dict(exclude_none=True))
+        ).get_links(extra_links=collection["links"])
+        collection["links"] = links
+        return collection
 
-    async def get_item(
-        self, item_id: str, collection_id: str, **kwargs
-    ) -> ORJSONResponse:
+    async def get_item(self, item_id: str, collection_id: str, **kwargs) -> Item:
         """Get item by id.
 
         Called with `GET /collections/{collectionId}/items/{itemId}`.
@@ -268,11 +182,11 @@ async def get_item(
         """
         req = PgstacSearch(ids=[item_id], limit=1)
         collection = await self._search_base(req, **kwargs)
-        return ORJSONResponse(collection.features[0])
+        return Item(**collection["features"][0])
 
     async def post_search(
         self, search_request: PgstacSearch, **kwargs
-    ) -> ORJSONResponse:
+    ) -> ItemCollection:
         """Cross catalog search (POST).
 
         Called with `POST /search`.
@@ -283,8 +197,8 @@ async def post_search(
         Returns:
             ItemCollection containing items which match the search criteria.
         """
-        collection = await self._search_base(search_request, **kwargs)
-        return ORJSONResponse(collection.dict(exclude_none=True))
+        item_collection = await self._search_base(search_request, **kwargs)
+        return ItemCollection(**item_collection)
 
     async def get_search(
         self,
@@ -298,7 +212,7 @@ async def get_search(
         fields: Optional[List[str]] = None,
         sortby: Optional[str] = None,
         **kwargs,
-    ) -> ORJSONResponse:
+    ) -> ItemCollection:
         """Cross catalog search (GET).
 
         Called with `GET /search`.
@@ -323,13 +237,13 @@ async def get_search(
             sort_param = []
             for sort in sortby:
                 sortparts = re.match(r"^([+-]?)(.*)$", sort)
-
-                sort_param.append(
-                    {
-                        "field": sortparts.group(2).strip(),
-                        "direction": "desc" if sortparts.group(1) == "-" else "asc",
-                    }
-                )
+                if sortparts:
+                    sort_param.append(
+                        {
+                            "field": sortparts.group(2).strip(),
+                            "direction": "desc" if sortparts.group(1) == "-" else "asc",
+                        }
+                    )
             base_args["sortby"] = sort_param
 
         if fields:
diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/db.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/db.py
index 63d7f915a..2991a92a5 100644
--- a/stac_fastapi/pgstac/stac_fastapi/pgstac/db.py
+++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/db.py
@@ -1,5 +1,6 @@
 """Database connection handling."""
 
+import json
 from typing import Dict, Union
 
 import attr
@@ -76,7 +77,7 @@ async def dbfunc(pool: pool, func: str, arg: Union[str, Dict]):
                     f"""
                         SELECT * FROM {func}(:item::text::jsonb);
                         """,
-                    item=arg.json(exclude_unset=True),
+                    item=json.dumps(arg),
                 )
                 return await conn.fetchval(q, *p)
     except exceptions.UniqueViolationError as e:
diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/models/links.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/models/links.py
index aef02d802..dadfc6e8f 100644
--- a/stac_fastapi/pgstac/stac_fastapi/pgstac/models/links.py
+++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/models/links.py
@@ -1,15 +1,13 @@
 """link helpers."""
 
-from typing import Dict, List, Union
+from typing import Any, Dict, List, Optional
 from urllib.parse import ParseResult, parse_qs, unquote, urlencode, urljoin, urlparse
 
 import attr
-from stac_pydantic.links import Link, PaginationLink, Relations
+from stac_pydantic.links import Relations
 from stac_pydantic.shared import MimeTypes
 from starlette.requests import Request
 
-from stac_fastapi.extensions.third_party.tiles import OGCTileLink
-
 # These can be inferred from the item/collection so they aren't included in the database
 # Instead they are dynamically generated when querying the database using the classes defined below
 INFERRED_LINK_RELS = ["self", "item", "parent", "collection", "root"]
@@ -58,15 +56,17 @@ def resolve(self, url):
         """Resolve url to the current request url."""
         return urljoin(str(self.base_url), str(url))
 
-    def link_self(self) -> Link:
+    def link_self(self) -> Dict:
         """Return the self link."""
-        return Link(rel=Relations.self, type=MimeTypes.json, href=self.url)
+        return dict(rel=Relations.self.value, type=MimeTypes.json.value, href=self.url)
 
-    def link_root(self) -> Link:
+    def link_root(self) -> Dict:
         """Return the catalog root."""
-        return Link(rel=Relations.root, type=MimeTypes.json, href=self.base_url)
+        return dict(
+            rel=Relations.root.value, type=MimeTypes.json.value, href=self.base_url
+        )
 
-    def create_links(self) -> List[Union[PaginationLink, Link]]:
+    def create_links(self) -> List[Dict]:
         """Return all inferred links."""
         links = []
         for name in dir(self):
@@ -77,14 +77,15 @@ def create_links(self) -> List[Union[PaginationLink, Link]]:
         return links
 
     async def get_links(
-        self, extra_links: List[Union[PaginationLink, Link]] = []
-    ) -> List[Union[PaginationLink, Link]]:
+        self, extra_links: List[Dict[str, Any]] = []
+    ) -> List[Dict[str, Any]]:
         """
         Generate all the links.
 
         Get the links object for a stac resource by iterating through
         available methods on this class that start with link_.
         """
+        # TODO: Pass request.json() into function so this doesn't need to be coroutine
         if self.request.method == "POST":
             self.request.postbody = await self.request.json()
         # join passed in links with generated links
@@ -92,8 +93,8 @@ async def get_links(
         links = self.create_links()
         if extra_links is not None and len(extra_links) >= 1:
             for link in extra_links:
-                if link.rel not in INFERRED_LINK_RELS:
-                    link.href = self.resolve(link.href)
+                if link["rel"] not in INFERRED_LINK_RELS:
+                    link["href"] = self.resolve(link["href"])
                     links.append(link)
         return links
 
@@ -102,55 +103,54 @@ async def get_links(
 class PagingLinks(BaseLinks):
     """Create links for paging."""
 
-    next: str = attr.ib(kw_only=True, default=None)
-    prev: str = attr.ib(kw_only=True, default=None)
+    next: Optional[str] = attr.ib(kw_only=True, default=None)
+    prev: Optional[str] = attr.ib(kw_only=True, default=None)
 
-    def link_next(self) -> PaginationLink:
+    def link_next(self) -> Optional[Dict[str, Any]]:
         """Create link for next page."""
         if self.next is not None:
             method = self.request.method
             if method == "GET":
                 href = merge_params(self.url, {"token": f"next:{self.next}"})
-                link = PaginationLink(
-                    rel=Relations.next,
-                    type=MimeTypes.json,
+                link = dict(
+                    rel=Relations.next.value,
+                    type=MimeTypes.json.value,
                     method=method,
                     href=href,
                 )
                 return link
             if method == "POST":
-                body = self.request.postbody
-                body["token"] = f"next:{self.next}"
-                return PaginationLink(
-                    rel=Relations.next,
-                    type=MimeTypes.json,
-                    method=method,
-                    href=f"{self.request.url}",
-                    body=body,
-                )
+                return {
+                    "rel": Relations.next,
+                    "type": MimeTypes.json,
+                    "method": method,
+                    "href": f"{self.request.url}",
+                    "body": {**self.request.postbody, "token": f"next:{self.next}"},
+                }
 
-    def link_prev(self) -> PaginationLink:
+        return None
+
+    def link_prev(self) -> Optional[Dict[str, Any]]:
         """Create link for previous page."""
         if self.prev is not None:
             method = self.request.method
             if method == "GET":
                 href = merge_params(self.url, {"token": f"prev:{self.prev}"})
-                return PaginationLink(
-                    rel=Relations.previous,
-                    type=MimeTypes.json,
+                return dict(
+                    rel=Relations.previous.value,
+                    type=MimeTypes.json.value,
                     method=method,
                     href=href,
                 )
             if method == "POST":
-                body = self.request.postbody
-                body["token"] = f"prev:{self.prev}"
-                return PaginationLink(
-                    rel=Relations.previous,
-                    type=MimeTypes.json,
-                    method=method,
-                    href=f"{self.request.url}",
-                    body=body,
-                )
+                return {
+                    "rel": Relations.previous,
+                    "type": MimeTypes.json,
+                    "method": method,
+                    "href": f"{self.request.url}",
+                    "body": {**self.request.postbody, "token": f"prev:{self.prev}"},
+                }
+        return None
 
 
 @attr.s
@@ -159,11 +159,11 @@ class CollectionLinksBase(BaseLinks):
 
     collection_id: str = attr.ib()
 
-    def collection_link(self, rel=Relations.collection) -> Link:
+    def collection_link(self, rel: str = Relations.collection.value) -> Dict:
         """Create a link to a collection."""
-        return Link(
+        return dict(
             rel=rel,
-            type=MimeTypes.json,
+            type=MimeTypes.json.value,
             href=self.resolve(f"/collections/{self.collection_id}"),
         )
 
@@ -172,23 +172,23 @@ def collection_link(self, rel=Relations.collection) -> Link:
 class CollectionLinks(CollectionLinksBase):
     """Create inferred links specific to collections."""
 
-    def link_self(self) -> Link:
+    def link_self(self) -> Dict:
         """Return the self link."""
-        return self.collection_link(rel=Relations.self)
+        return self.collection_link(rel=Relations.self.value)
 
-    def link_parent(self) -> Link:
+    def link_parent(self) -> Dict:
         """Create the `parent` link."""
-        return Link(
-            rel=Relations.parent,
-            type=MimeTypes.json,
+        return dict(
+            rel=Relations.parent.value,
+            type=MimeTypes.json.value,
             href=self.base_url,
         )
 
-    def link_items(self) -> Link:
+    def link_items(self) -> Dict:
         """Create the `item` link."""
-        return Link(
+        return dict(
             rel="items",
-            type=MimeTypes.geojson,
+            type=MimeTypes.geojson.value,
             href=self.resolve(f"/collections/{self.collection_id}/items"),
         )
 
@@ -199,29 +199,29 @@ class ItemLinks(CollectionLinksBase):
 
     item_id: str = attr.ib()
 
-    def link_self(self) -> Link:
+    def link_self(self) -> Dict:
         """Create the self link."""
-        return Link(
-            rel=Relations.self,
-            type=MimeTypes.geojson,
+        return dict(
+            rel=Relations.self.value,
+            type=MimeTypes.geojson.value,
             href=self.resolve(
                 f"/collections/{self.collection_id}/items/{self.item_id}"
             ),
         )
 
-    def link_parent(self) -> Link:
+    def link_parent(self) -> Dict:
         """Create the `parent` link."""
-        return self.collection_link(rel=Relations.parent)
+        return self.collection_link(rel=Relations.parent.value)
 
-    def link_collection(self) -> Link:
+    def link_collection(self) -> Dict:
         """Create the `collection` link."""
         return self.collection_link()
 
-    def link_tiles(self) -> Link:
+    def link_tiles(self) -> Dict:
         """Create the `tiles` link."""
-        return Link(
-            rel=Relations.alternate,
-            type=MimeTypes.json,
+        return dict(
+            rel=Relations.alternate.value,
+            type=MimeTypes.json.value,
             title="tiles",
             href=self.resolve(
                 f"/collections/{self.collection_id}/items/{self.item_id}/tiles",
@@ -244,45 +244,45 @@ def __post_init__(self):
             f"/collections/{self.collection_id}/items/{self.item_id}",
         )
 
-    def link_tiles(self) -> OGCTileLink:
+    def link_tiles(self) -> Dict:
         """Create tiles link."""
-        return OGCTileLink(
+        return dict(
             href=urljoin(
                 self.base_url,
                 f"/titiler/tiles/{{z}}/{{x}}/{{y}}.png?url={self.item_uri}",
             ),
-            rel=Relations.item,
+            rel=Relations.item.value,
             title="tiles",
-            type=MimeTypes.png,
+            type=MimeTypes.png.value,
             templated=True,
         )
 
-    def link_viewer(self) -> OGCTileLink:
+    def link_viewer(self) -> Dict:
         """Create viewer link."""
-        return OGCTileLink(
+        return dict(
             href=urljoin(self.base_url, f"/titiler/viewer?url={self.item_uri}"),
-            rel=Relations.alternate,
-            type=MimeTypes.html,
+            rel=Relations.alternate.value,
+            type=MimeTypes.html.value,
             title="viewer",
         )
 
-    def link_tilejson(self) -> OGCTileLink:
+    def link_tilejson(self) -> Dict:
         """Create tilejson link."""
-        return OGCTileLink(
+        return dict(
             href=urljoin(self.base_url, f"/titiler/tilejson.json?url={self.item_uri}"),
-            rel=Relations.alternate,
-            type=MimeTypes.json,
+            rel=Relations.alternate.value,
+            type=MimeTypes.json.value,
             title="tilejson",
         )
 
-    def link_wmts(self) -> OGCTileLink:
+    def link_wmts(self) -> Dict:
         """Create wmts capabilities link."""
-        return OGCTileLink(
+        return dict(
             href=urljoin(
                 self.base_url,
                 f"/titiler/WMTSCapabilities.xml?url={self.item_uri}",
             ),
-            rel=Relations.alternate,
-            type=MimeTypes.xml,
+            rel=Relations.alternate.value,
+            type=MimeTypes.xml.value,
             title="WMTS Capabilities",
         )
diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/models/schemas.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/models/schemas.py
deleted file mode 100644
index 4b3e00642..000000000
--- a/stac_fastapi/pgstac/stac_fastapi/pgstac/models/schemas.py
+++ /dev/null
@@ -1,44 +0,0 @@
-"""API pydantic models."""
-
-from datetime import datetime
-from typing import List, Optional, Union
-
-from geojson_pydantic.geometries import Polygon
-from pydantic import BaseModel
-from stac_pydantic import Collection as CollectionBase
-from stac_pydantic import Item as ItemBase
-from stac_pydantic.links import Link
-from stac_pydantic.shared import DATETIME_RFC339
-
-# Be careful: https://github.com/samuelcolvin/pydantic/issues/1423#issuecomment-642797287
-NumType = Union[float, int]
-
-
-class Collection(CollectionBase):
-    """Collection model."""
-
-    links: Optional[List[Link]]
-
-    class Config:
-        """Model config."""
-
-        use_enum_values = True
-
-
-class Item(ItemBase):
-    """Item model."""
-
-    geometry: Polygon
-    links: Optional[List[Link]]
-
-    class Config:
-        """Model config."""
-
-        json_encoders = {datetime: lambda v: v.strftime(DATETIME_RFC339)}
-        use_enum_values = True
-
-
-class Items(BaseModel):
-    """Items model."""
-
-    items: List[Item]
diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py
index a331b7519..ca835a7bd 100644
--- a/stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py
+++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py
@@ -1,67 +1,64 @@
 """transactions extension client."""
 
 import logging
+from typing import Dict
 
 import attr
-from fastapi.responses import ORJSONResponse
-from stac_pydantic import Item
 
 from stac_fastapi.pgstac.db import dbfunc
-from stac_fastapi.pgstac.models import schemas
-from stac_fastapi.types.core import BaseTransactionsClient
+from stac_fastapi.types import stac as stac_types
+from stac_fastapi.types.core import AsyncBaseTransactionsClient
 
 logger = logging.getLogger("uvicorn")
 logger.setLevel(logging.INFO)
 
 
 @attr.s
-class TransactionsClient(BaseTransactionsClient):
+class TransactionsClient(AsyncBaseTransactionsClient):
     """Transactions extension specific CRUD operations."""
 
-    async def create_item(item: schemas.Item = None, **kwargs) -> Item:
+    async def create_item(self, item: stac_types.Item, **kwargs) -> stac_types.Item:
         """Create item."""
         request = kwargs["request"]
         pool = request.app.state.writepool
         await dbfunc(pool, "create_item", item)
-        return ORJSONResponse(item.dict())
+        return item
 
-    async def update_item(item: schemas.Item = None, **kwargs) -> Item:
+    async def update_item(self, item: stac_types.Item, **kwargs) -> stac_types.Item:
         """Update item."""
         request = kwargs["request"]
         pool = request.app.state.writepool
         await dbfunc(pool, "update_item", item)
-        return ORJSONResponse(item.dict())
+        return item
 
     async def create_collection(
-        collection: schemas.Collection, **kwargs
-    ) -> schemas.Collection:
+        self, collection: stac_types.Collection, **kwargs
+    ) -> stac_types.Collection:
         """Create collection."""
         request = kwargs["request"]
         pool = request.app.state.writepool
         await dbfunc(pool, "create_collection", collection)
-        return ORJSONResponse(collection.dict())
+        return collection
 
     async def update_collection(
-        collection: schemas.Collection, **kwargs
-    ) -> schemas.Collection:
+        self, collection: stac_types.Collection, **kwargs
+    ) -> stac_types.Collection:
         """Update collection."""
         request = kwargs["request"]
         pool = request.app.state.writepool
         await dbfunc(pool, "update_collection", collection)
-        return ORJSONResponse(collection.dict())
+        return collection
 
-    async def delete_item(
-        item_id: str, collection_id: str, **kwargs
-    ) -> schemas.Collection:
+    async def delete_item(self, item_id: str, collection_id: str, **kwargs) -> Dict:
         """Delete collection."""
         request = kwargs["request"]
         pool = request.app.state.writepool
         await dbfunc(pool, "delete_item", item_id)
-        return ORJSONResponse({"deleted item": item_id})
+        return {"deleted item": item_id}
 
-    async def delete_collection(id: str, **kwargs) -> schemas.Collection:
+    async def delete_collection(self, id: str, **kwargs) -> Dict:
         """Delete collection."""
         request = kwargs["request"]
         pool = request.app.state.writepool
         await dbfunc(pool, "delete_collection", id)
-        return ORJSONResponse({"deleted collection": id})
+        return {"deleted collection": id}
diff --git a/stac_fastapi/sqlalchemy/alembic/versions/131aab4d9e49_create_tables.py b/stac_fastapi/sqlalchemy/alembic/versions/131aab4d9e49_create_tables.py
index 620bedcb2..efc333803 100644
--- a/stac_fastapi/sqlalchemy/alembic/versions/131aab4d9e49_create_tables.py
+++ b/stac_fastapi/sqlalchemy/alembic/versions/131aab4d9e49_create_tables.py
@@ -1,7 +1,7 @@
 """create initial schema
 
 Revision ID: 131aab4d9e49
-Revises: 
+Revises:
 Create Date: 2020-02-09 13:03:09.336631
 
 """  # noqa
diff --git a/stac_fastapi/sqlalchemy/setup.py b/stac_fastapi/sqlalchemy/setup.py
index dadfa624d..5f8d2f5d0 100644
--- a/stac_fastapi/sqlalchemy/setup.py
+++ b/stac_fastapi/sqlalchemy/setup.py
@@ -9,7 +9,7 @@
     "fastapi",
     "attrs",
     "pydantic[dotenv]",
-    "stac_pydantic==2.0.0",
+    "stac_pydantic==2.0.*",
     "stac-fastapi.types",
     "stac-fastapi.api",
     "stac-fastapi.extensions",
diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/__init__.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/__init__.py
index fd914c27b..ee2522f79 100644
--- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/__init__.py
+++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/__init__.py
@@ -1,1 +1,1 @@
-"""postgres submodule."""
+"""sqlalchemy submodule."""
diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py
index 85f4b6f1f..c70231171 100644
--- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py
+++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py
@@ -21,7 +21,9 @@
 api = StacApi(
     settings=settings,
     extensions=[
-        TransactionExtension(client=TransactionsClient(session=session)),
+        TransactionExtension(
+            client=TransactionsClient(session=session), settings=settings
+        ),
         BulkTransactionExtension(client=BulkTransactionsClient(session=session)),
         FieldsExtension(),
         QueryExtension(),
diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py
index c0b624208..49f46fd31 100644
--- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py
+++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py
@@ -2,29 +2,31 @@
 import json
 import logging
 from datetime import datetime
-from typing import Any, Dict, List, Optional, Set, Type, Union
+from typing import List, Optional, Set, Type, Union
 from urllib.parse import urlencode
 
 import attr
 import geoalchemy2 as ga
 import sqlalchemy as sa
+import stac_pydantic
 from shapely.geometry import Polygon as ShapelyPolygon
 from shapely.geometry import shape
 from sqlakeyset import get_page
 from sqlalchemy import func
 from sqlalchemy.orm import Session as SqlSession
-from stac_pydantic import ItemCollection
-from stac_pydantic.api import ConformanceClasses
-from stac_pydantic.links import PaginationLink, Relations
+from stac_pydantic.links import Relations
+from stac_pydantic.version import STAC_VERSION
 
 from stac_fastapi.extensions.core import ContextExtension, FieldsExtension
-from stac_fastapi.sqlalchemy.models import database, schemas
+from stac_fastapi.sqlalchemy import serializers
+from stac_fastapi.sqlalchemy.models import database
 from stac_fastapi.sqlalchemy.session import Session
 from stac_fastapi.sqlalchemy.tokens import PaginationTokenClient
 from stac_fastapi.sqlalchemy.types.search import SQLAlchemySTACSearch
 from stac_fastapi.types.config import Settings
 from stac_fastapi.types.core import BaseCoreClient
 from stac_fastapi.types.errors import NotFoundError
+from stac_fastapi.types.stac import Collection, Conformance, Item, ItemCollection
 
 logger = logging.getLogger(__name__)
 
@@ -38,6 +40,12 @@ class CoreCrudClient(PaginationTokenClient, BaseCoreClient):
     session: Session = attr.ib(default=attr.Factory(Session.create_from_env))
     item_table: Type[database.Item] = attr.ib(default=database.Item)
     collection_table: Type[database.Collection] = attr.ib(default=database.Collection)
+    item_serializer: Type[serializers.Serializer] = attr.ib(
+        default=serializers.ItemSerializer
+    )
+    collection_serializer: Type[serializers.Serializer] = attr.ib(
+        default=serializers.CollectionSerializer
+    )
 
     @staticmethod
     def _lookup_id(
@@ -49,37 +57,38 @@ def _lookup_id(
             raise NotFoundError(f"{table.__name__} {id} not found")
         return row
 
-    def conformance(self, **kwargs) -> ConformanceClasses:
+    def conformance(self, **kwargs) -> Conformance:
         """Conformance classes."""
-        return ConformanceClasses(
+        return Conformance(
             conformsTo=[
                 "https://stacspec.org/STAC-api.html",
                 "http://docs.opengeospatial.org/is/17-069r3/17-069r3.html#ats_geojson",
             ]
         )
 
-    def all_collections(self, **kwargs) -> List[schemas.Collection]:
+    def all_collections(self, **kwargs) -> List[Collection]:
         """Read all collections from the database."""
+        base_url = str(kwargs["request"].base_url)
         with self.session.reader.context_session() as session:
             collections = session.query(self.collection_table).all()
-            response = []
-            for collection in collections:
-                collection.base_url = str(kwargs["request"].base_url)
-                response.append(schemas.Collection.from_orm(collection))
+            response = [
+                self.collection_serializer.db_to_stac(collection, base_url=base_url)
+                for collection in collections
+            ]
             return response
 
-    def get_collection(self, id: str, **kwargs) -> schemas.Collection:
+    def get_collection(self, id: str, **kwargs) -> Collection:
         """Get collection by id."""
+        base_url = str(kwargs["request"].base_url)
         with self.session.reader.context_session() as session:
             collection = self._lookup_id(id, self.collection_table, session)
-            # TODO: Don't do this
-            collection.base_url = str(kwargs["request"].base_url)
-            return schemas.Collection.from_orm(collection)
+            return self.collection_serializer.db_to_stac(collection, base_url)
 
     def item_collection(
         self, id: str, limit: int = 10, token: str = None, **kwargs
     ) -> ItemCollection:
         """Read an item collection from the database."""
+        base_url = str(kwargs["request"].base_url)
         with self.session.reader.context_session() as session:
             collection_children = (
                 session.query(self.item_table)
@@ -110,27 +119,28 @@ def item_collection(
             links = []
             if page.next:
                 links.append(
-                    PaginationLink(
-                        rel=Relations.next,
-                        type="application/geo+json",
-                        href=f"{kwargs['request'].base_url}collections/{id}/items?token={page.next}&limit={limit}",
-                        method="GET",
-                    )
+                    {
+                        "rel": Relations.next.value,
+                        "type": "application/geo+json",
+                        "href": f"{kwargs['request'].base_url}collections/{id}/items?token={page.next}&limit={limit}",
+                        "method": "GET",
+                    }
                 )
             if page.previous:
                 links.append(
-                    PaginationLink(
-                        rel=Relations.previous,
-                        type="application/geo+json",
-                        href=f"{kwargs['request'].base_url}collections/{id}/items?token={page.previous}&limit={limit}",
-                        method="GET",
-                    )
+                    {
+                        "rel": Relations.previous.value,
+                        "type": "application/geo+json",
+                        "href": f"{kwargs['request'].base_url}collections/{id}/items?token={page.previous}&limit={limit}",
+                        "method": "GET",
+                    }
                 )
 
             response_features = []
             for item in page:
-                item.base_url = str(kwargs["request"].base_url)
-                response_features.append(schemas.Item.from_orm(item))
+                response_features.append(
+                    self.item_serializer.db_to_stac(item, base_url=base_url)
+                )
 
             context_obj = None
             if self.extension_is_enabled(ContextExtension):
@@ -140,19 +150,21 @@ def item_collection(
                     "matched": count,
                 }
 
+            # TODO: return stac_extensions
             return ItemCollection(
                 type="FeatureCollection",
-                context=context_obj,
+                stac_version=STAC_VERSION,
                 features=response_features,
                 links=links,
+                context=context_obj,
             )
 
-    def get_item(self, item_id: str, collection_id: str, **kwargs) -> schemas.Item:
+    def get_item(self, item_id: str, collection_id: str, **kwargs) -> Item:
         """Get item by id."""
+        base_url = str(kwargs["request"].base_url)
         with self.session.reader.context_session() as session:
             item = self._lookup_id(item_id, self.item_table, session)
-            item.base_url = str(kwargs["request"].base_url)
-            return schemas.Item.from_orm(item)
+            return self.item_serializer.db_to_stac(item, base_url=base_url)
 
     def get_search(
         self,
@@ -166,7 +178,7 @@ def get_search(
         fields: Optional[List[str]] = None,
         sortby: Optional[str] = None,
         **kwargs,
-    ) -> Dict[str, Any]:
+    ) -> ItemCollection:
         """GET search catalog."""
         # Parse request parameters
         base_args = {
@@ -210,14 +222,14 @@ def get_search(
         # Pagination
         page_links = []
         for link in resp["links"]:
-            if link.rel == Relations.next or link.rel == Relations.previous:
+            if link["rel"] == Relations.next or link["rel"] == Relations.previous:
                 query_params = dict(kwargs["request"].query_params)
-                if link.body and link.merge:
-                    query_params.update(link.body)
-                link.method = "GET"
-                link.href = f"{link.href}?{urlencode(query_params)}"
-                link.body = None
-                link.merge = False
+                if link["body"] and link["merge"]:
+                    query_params.update(link["body"])
+                link["method"] = "GET"
+                link["href"] = f"{link['body']}?{urlencode(query_params)}"
+                link["body"] = None
+                link["merge"] = False
                 page_links.append(link)
             else:
                 page_links.append(link)
@@ -226,8 +238,9 @@ def get_search(
 
     def post_search(
         self, search_request: SQLAlchemySTACSearch, **kwargs
-    ) -> Dict[str, Any]:
+    ) -> ItemCollection:
         """POST search catalog."""
+        base_url = str(kwargs["request"].base_url)
         with self.session.reader.context_session() as session:
             token = (
                 self.get_token(search_request.token) if search_request.token else False
@@ -338,29 +351,34 @@ def post_search(
             links = []
             if page.next:
                 links.append(
-                    PaginationLink(
-                        rel=Relations.next,
-                        type="application/geo+json",
-                        href=f"{kwargs['request'].base_url}search",
-                        method="POST",
-                        body={"token": page.next},
-                        merge=True,
-                    )
+                    {
+                        "rel": Relations.next.value,
+                        "type": "application/geo+json",
+                        "href": f"{kwargs['request'].base_url}search",
+                        "method": "POST",
+                        "body": {"token": page.next},
+                        "merge": True,
+                    }
                 )
             if page.previous:
                 links.append(
-                    PaginationLink(
-                        rel=Relations.previous,
-                        type="application/geo+json",
-                        href=f"{kwargs['request'].base_url}search",
-                        method="POST",
-                        body={"token": page.previous},
-                        merge=True,
-                    )
+                    {
+                        "rel": Relations.previous.value,
+                        "type": "application/geo+json",
+                        "href": f"{kwargs['request'].base_url}search",
+                        "method": "POST",
+                        "body": {"token": page.previous},
+                        "merge": True,
+                    }
                 )
 
             response_features = []
-            filter_kwargs = {}
+            for item in page:
+                response_features.append(
+                    self.item_serializer.db_to_stac(item, base_url=base_url)
+                )
+
+            # Use pydantic includes/excludes syntax to implement fields extension
             if self.extension_is_enabled(FieldsExtension):
                 if search_request.query is not None:
                     query_include: Set[str] = set(
@@ -377,20 +395,12 @@ def post_search(
                         search_request.field.include.union(query_include)
 
                 filter_kwargs = search_request.field.filter_fields
-
-            xvals = []
-            yvals = []
-            for item in page:
-                item.base_url = str(kwargs["request"].base_url)
-                item_model = schemas.Item.from_orm(item)
-                xvals += [item_model.bbox[0], item_model.bbox[2]]
-                yvals += [item_model.bbox[1], item_model.bbox[3]]
-                response_features.append(item_model.to_dict(**filter_kwargs))
-
-        try:
-            bbox = (min(xvals), min(yvals), max(xvals), max(yvals))
-        except ValueError:
-            bbox = None
+                # Need to pass through `.json()` for proper serialization
+                # of datetime
+                response_features = [
+                    json.loads(stac_pydantic.Item(**feat).json(**filter_kwargs))
+                    for feat in response_features
+                ]
 
         context_obj = None
         if self.extension_is_enabled(ContextExtension):
@@ -400,10 +410,11 @@ def post_search(
                 "matched": count,
             }
 
-        return {
-            "type": "FeatureCollection",
-            "context": context_obj,
-            "features": response_features,
-            "links": links,
-            "bbox": bbox,
-        }
+        # TODO: return stac_extensions
+        return ItemCollection(
+            type="FeatureCollection",
+            stac_version=STAC_VERSION,
+            features=response_features,
+            links=links,
+            context=context_obj,
+        )
diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/models/database.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/models/database.py
index 506f01b92..68444bcab 100644
--- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/models/database.py
+++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/models/database.py
@@ -1,19 +1,14 @@
 """SQLAlchemy ORM models."""
 
 import json
-from datetime import datetime
 from typing import Optional
 
 import geoalchemy2 as ga
 import sqlalchemy as sa
-from shapely.geometry import shape
 from sqlalchemy.dialects.postgresql import JSONB
 from sqlalchemy.ext.declarative import declarative_base
-from stac_pydantic.shared import DATETIME_RFC339
 
-from stac_fastapi.sqlalchemy.models import schemas
 from stac_fastapi.sqlalchemy.types.search import Queryables, QueryableTypes
-from stac_fastapi.types.config import Settings
 
 BaseModel = declarative_base()
 
@@ -59,16 +54,6 @@ class Collection(BaseModel):  # type:ignore
     children = sa.orm.relationship("Item", lazy="dynamic")
     type = sa.Column(sa.VARCHAR(300), nullable=False)
 
-    @classmethod
-    def get_database_model(cls, schema: schemas.Collection) -> dict:
-        """Decompose pydantic model to data model."""
-        return schema.dict(exclude_none=True)
-
-    @classmethod
-    def from_schema(cls, schema: schemas.Collection) -> "Collection":
-        """Create orm model from pydantic model."""
-        return cls(**cls.get_database_model(schema))
-
 
 class Item(BaseModel):  # type:ignore
     """Item orm model."""
@@ -90,49 +75,6 @@ class Item(BaseModel):  # type:ignore
     datetime = sa.Column(sa.TIMESTAMP(timezone=True), nullable=False)
     links = sa.Column(JSONB)
 
-    @classmethod
-    def get_database_model(cls, schema: schemas.Item) -> dict:
-        """Decompose pydantic model to data model."""
-        indexed_fields = {}
-        for field in Settings.get().indexed_fields:
-            # Use getattr to accommodate extension namespaces
-            field_value = getattr(schema.properties, field)
-            if field == "datetime" and not isinstance(field_value, datetime):
-                field_value = datetime.strptime(field_value, DATETIME_RFC339)
-            indexed_fields[field.split(":")[-1]] = field_value
-
-        # Exclude indexed fields from the properties jsonb field
-        properties = schema.properties.dict(exclude=set(Settings.get().indexed_fields))
-        now = datetime.utcnow().strftime(DATETIME_RFC339)
-        if not properties["created"]:
-            properties["created"] = now
-        else:
-            # If there isn't a created field initially it is already included in the pydantic model.
-            # Which means its typed as a datetime.datetime object, but sqlalchemy needs this to be a string.
-            # Probably don't need the isinstance check here but it's a little safer.
-            if isinstance(properties["created"], datetime):
-                properties["created"] = properties["created"].strftime(DATETIME_RFC339)
-        properties["updated"] = now
-
-        return dict(
-            collection_id=schema.collection,
-            geometry=ga.shape.from_shape(shape(schema.geometry), 4326),
-            properties=properties,
-            **indexed_fields,
-            **schema.dict(
-                exclude_none=True,
-                exclude=set(
-                    Settings().get().forbidden_fields
-                    | {"geometry", "properties", "collection"}
-                ),
-            )
-        )
-
-    @classmethod
-    def from_schema(cls, schema: schemas.Item) -> "Item":
-        """Create orm model from pydantic model."""
-        return cls(**cls.get_database_model(schema))
-
     @classmethod
     def get_field(cls, field_name):
         """Get a model field."""
diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/models/decompose.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/models/decompose.py
deleted file mode 100644
index d3ef703c9..000000000
--- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/models/decompose.py
+++ /dev/null
@@ -1,113 +0,0 @@
-"""Model serialization."""
-import json
-from typing import Any, Dict, List, Union
-from urllib.parse import urljoin
-
-import geoalchemy2 as ga
-from pydantic import BaseModel
-from pydantic.utils import GetterDict
-from stac_pydantic.shared import DATETIME_RFC339
-
-from stac_fastapi.types.config import Settings
-from stac_fastapi.types.errors import DatabaseError
-from stac_fastapi.types.links import CollectionLinks, ItemLinks, filter_links
-
-
-def resolve_links(links: list, base_url: str) -> List[Dict]:
-    """Convert relative links to absolute links."""
-    if isinstance(links[0], BaseModel):
-        links = [link.dict() for link in links]
-    filtered_links = filter_links(links)
-    for link in filtered_links:
-        link.update({"href": urljoin(base_url, link["href"])})
-    return filtered_links
-
-
-class ItemGetter(GetterDict):
-    """Custom GetterDict.
-
-    Custom GetterDict used internally by pydantic ORM mode when decomposing database model to pydantic model.  This
-    object resolves structural differences between the two models, for example:
-      - relative links stored in the database must be resolved absolute links and inferred links must be added
-      - ``datetime`` is defined as its own field in the database but as ``item.properties.datetime`` in the stac spec
-      - ``geometry`` can be one of several formats when exported from the database but the STAC item expects geojson
-    """
-
-    @staticmethod
-    def decode_geom(geom: Union[ga.elements.WKBElement, str, Dict]) -> Dict:
-        """Decode geoalchemy type to geojson."""
-        if isinstance(geom, ga.elements.WKBElement):
-            return json.loads(json.dumps(ga.shape.to_shape(geom).__geo_interface__))
-        elif isinstance(geom, str):
-            return json.loads(geom)
-        elif isinstance(geom, dict):
-            return geom
-        raise DatabaseError("Received unexpected geometry format from database")
-
-    def __init__(self, obj: Any):
-        """Decompose orm model to pydantic model."""
-        properties = obj.properties.copy()
-        for field in Settings.get().indexed_fields:
-            # Use getattr to accommodate extension namespaces
-            field_value = getattr(obj, field.split(":")[-1])
-            if field == "datetime":
-                field_value = field_value.strftime(DATETIME_RFC339)
-            properties[field] = field_value
-        # Create inferred links
-        item_links = ItemLinks(
-            collection_id=obj.collection_id,
-            base_url=obj.base_url,
-            item_id=obj.id,
-        ).create_links()
-        # Resolve existing links
-        if obj.links:
-            item_links += resolve_links(obj.links, obj.base_url)
-        db_model = obj.__class__(
-            id=obj.id,
-            stac_version=obj.stac_version,
-            geometry=self.decode_geom(obj.geometry),
-            bbox=obj.bbox,
-            properties=properties,
-            assets=obj.assets,
-            collection_id=obj.collection_id,
-            datetime=obj.datetime,
-            links=item_links,
-            stac_extensions=obj.stac_extensions,
-        )
-        db_model.type = "Feature"
-        db_model.collection = db_model.collection_id
-        super().__init__(db_model)
-
-
-class CollectionGetter(GetterDict):
-    """Custom GetterDict.
-
-    Used internally by pydantic ORM mode when collection ORM model to pydantic model
-    """
-
-    def __init__(self, obj: Any):
-        """Decompose orm model to pydantic model."""
-        # Create inferred links
-        collection_links = CollectionLinks(
-            collection_id=obj.id, base_url=obj.base_url
-        ).create_links()
-        # Resolve existing links
-        if obj.links:
-            collection_links += resolve_links(obj.links, obj.base_url)
-        # TODO: Fix bug in stac-pydantic (collection root validator) that requires coercing to empty list
-        stac_extensions = obj.stac_extensions or []
-        db_model = obj.__class__(
-            id=obj.id,
-            stac_extensions=stac_extensions,
-            stac_version=obj.stac_version,
-            title=obj.title,
-            description=obj.description,
-            keywords=obj.keywords,
-            license=obj.license,
-            providers=obj.providers,
-            summaries=obj.summaries,
-            extent=obj.extent,
-            links=collection_links,
-            type="collection",
-        )
-        super().__init__(db_model)
diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/models/schemas.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/models/schemas.py
deleted file mode 100644
index daecdcab8..000000000
--- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/models/schemas.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""API pydantic models."""
-
-from datetime import datetime
-from typing import List, Optional, Union
-
-from geojson_pydantic.geometries import Polygon
-from pydantic import BaseModel
-from stac_pydantic import Collection as CollectionBase
-from stac_pydantic import Item as ItemBase
-from stac_pydantic.links import Link
-from stac_pydantic.shared import DATETIME_RFC339
-
-from stac_fastapi.sqlalchemy.models.decompose import CollectionGetter, ItemGetter
-
-# Be careful: https://github.com/samuelcolvin/pydantic/issues/1423#issuecomment-642797287
-NumType = Union[float, int]
-
-
-class Collection(CollectionBase):
-    """Collection model."""
-
-    links: Optional[List[Link]]
-
-    class Config:
-        """Model config."""
-
-        orm_mode = True
-        use_enum_values = True
-        getter_dict = CollectionGetter
-
-
-class Item(ItemBase):
-    """Item model."""
-
-    geometry: Polygon
-    links: Optional[List[Link]]
-
-    class Config:
-        """Model config."""
-
-        json_encoders = {datetime: lambda v: v.strftime(DATETIME_RFC339)}
-        use_enum_values = True
-        orm_mode = True
-        getter_dict = ItemGetter
-
-
-class Items(BaseModel):
-    """Items model."""
-
-    items: List[Item]
diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/serializers.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/serializers.py
new file mode 100644
index 000000000..c1a04fbb0
--- /dev/null
+++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/serializers.py
@@ -0,0 +1,163 @@
+"""Serializers."""
+import abc
+import json
+from datetime import datetime
+from typing import TypedDict
+
+import attr
+import geoalchemy2 as ga
+from stac_pydantic.shared import DATETIME_RFC339
+
+from stac_fastapi.sqlalchemy.models import database
+from stac_fastapi.types import stac as stac_types
+from stac_fastapi.types.config import Settings
+from stac_fastapi.types.links import CollectionLinks, ItemLinks, resolve_links
+
+
+@attr.s  # type:ignore
+class Serializer(abc.ABC):
+    """Defines serialization methods between the API and the data model."""
+
+    @classmethod
+    @abc.abstractmethod
+    def db_to_stac(cls, db_model: database.BaseModel, base_url: str) -> TypedDict:
+        """Transform database model to stac."""
+        ...
+
+    @classmethod
+    @abc.abstractmethod
+    def stac_to_db(
+        cls, stac_data: TypedDict, exclude_geometry: bool = False
+    ) -> database.BaseModel:
+        """Transform stac to database model."""
+        ...
+
+    @classmethod
+    def row_to_dict(cls, db_model: database.BaseModel):
+        """Transform a database model to it's dictionary representation."""
+        d = {}
+        for column in db_model.__table__.columns:
+            value = getattr(db_model, column.name)
+            if value:
+                d[column.name] = value
+        return d
+
+
+class ItemSerializer(Serializer):
+    """Serialization methods for STAC items."""
+
+    @classmethod
+    def db_to_stac(cls, db_model: database.Item, base_url: str) -> stac_types.Item:
+        """Transform database model to stac item."""
+        properties = db_model.properties.copy()
+        indexed_fields = Settings.get().indexed_fields
+        for field in indexed_fields:
+            # Use getattr to accommodate extension namespaces
+            field_value = getattr(db_model, field.split(":")[-1])
+            if field == "datetime":
+                field_value = field_value.strftime(DATETIME_RFC339)
+            properties[field] = field_value
+        item_id = db_model.id
+        collection_id = db_model.collection_id
+        item_links = ItemLinks(
+            collection_id=collection_id, item_id=item_id, base_url=base_url
+        ).create_links()
+
+        db_links = db_model.links
+        if db_links:
+            item_links += resolve_links(db_links, base_url)
+
+        stac_extensions = db_model.stac_extensions or []
+
+        # The custom geometry we are using emits geojson if the geometry is bound to the database
+        # Otherwise it will return a geoalchemy2 WKBElement
+        # TODO: It's probably best to just remove the custom geometry type
+        geometry = db_model.geometry
+        if isinstance(geometry, ga.elements.WKBElement):
+            geometry = ga.shape.to_shape(geometry).__geo_interface__
+        if isinstance(geometry, str):
+            geometry = json.loads(geometry)
+
+        return stac_types.Item(
+            type="Feature",
+            stac_version=db_model.stac_version,
+            stac_extensions=stac_extensions,
+            id=db_model.id,
+            collection=db_model.collection_id,
+            geometry=geometry,
+            bbox=[float(x) for x in db_model.bbox],
+            properties=properties,
+            links=item_links,
+            assets=db_model.assets,
+        )
+
+    @classmethod
+    def stac_to_db(
+        cls, stac_data: TypedDict, exclude_geometry: bool = False
+    ) -> database.Item:
+        """Transform stac item to database model."""
+        indexed_fields = {}
+        for field in Settings.get().indexed_fields:
+            # Use getattr to accommodate extension namespaces
+            field_value = stac_data["properties"][field]
+            if field == "datetime":
+                field_value = datetime.strptime(field_value, DATETIME_RFC339)
+            indexed_fields[field.split(":")[-1]] = field_value
+
+            # TODO: Exclude indexed fields from the properties jsonb field to prevent duplication
+
+            now = datetime.utcnow().strftime(DATETIME_RFC339)
+            if "created" not in stac_data["properties"]:
+                stac_data["properties"]["created"] = now
+            stac_data["properties"]["updated"] = now
+
+        return database.Item(
+            id=stac_data["id"],
+            collection_id=stac_data["collection"],
+            stac_version=stac_data["stac_version"],
+            stac_extensions=stac_data.get("stac_extensions"),
+            geometry=json.dumps(stac_data["geometry"]),
+            bbox=stac_data["bbox"],
+            properties=stac_data["properties"],
+            assets=stac_data["assets"],
+            **indexed_fields,
+        )
+
+
+class CollectionSerializer(Serializer):
+    """Serialization methods for STAC collections."""
+
+    @classmethod
+    def db_to_stac(cls, db_model: database.Collection, base_url: str) -> TypedDict:
+        """Transform database model to stac collection."""
+        collection_links = CollectionLinks(
+            collection_id=db_model.id, base_url=base_url
+        ).create_links()
+
+        db_links = db_model.links
+        if db_links:
+            collection_links += resolve_links(db_links, base_url)
+
+        stac_extensions = db_model.stac_extensions or []
+
+        return stac_types.Collection(
+            type="Collection",
+            id=db_model.id,
+            stac_extensions=stac_extensions,
+            stac_version=db_model.stac_version,
+            title=db_model.title,
+            description=db_model.description,
+            keywords=db_model.keywords,
+            license=db_model.license,
+            providers=db_model.providers,
+            summaries=db_model.summaries,
+            extent=db_model.extent,
+            links=collection_links,
+        )
+
+    @classmethod
+    def stac_to_db(
+        cls, stac_data: TypedDict, exclude_geometry: bool = False
+    ) -> database.Collection:
+        """Transform stac collection to database model."""
+        return database.Collection(**dict(stac_data))
diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/transactions.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/transactions.py
index be09cf194..9dba3d783 100644
--- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/transactions.py
+++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/transactions.py
@@ -2,16 +2,18 @@
 
 import json
 import logging
-from typing import Dict, Optional, Type
+from typing import Optional, Type
 
 import attr
 
-# TODO: This import should come from `backend` module
 from stac_fastapi.extensions.third_party.bulk_transactions import (
     BaseBulkTransactionsClient,
+    Items,
 )
-from stac_fastapi.sqlalchemy.models import database, schemas
+from stac_fastapi.sqlalchemy import serializers
+from stac_fastapi.sqlalchemy.models import database
 from stac_fastapi.sqlalchemy.session import Session
+from stac_fastapi.types import stac as stac_types
 from stac_fastapi.types.core import BaseTransactionsClient
 from stac_fastapi.types.errors import NotFoundError
 
@@ -25,75 +27,81 @@ class TransactionsClient(BaseTransactionsClient):
     session: Session = attr.ib(default=attr.Factory(Session.create_from_env))
     collection_table: Type[database.Collection] = attr.ib(default=database.Collection)
     item_table: Type[database.Item] = attr.ib(default=database.Item)
-
-    def create_item(self, model: schemas.Item, **kwargs) -> schemas.Item:
+    item_serializer: Type[serializers.Serializer] = attr.ib(
+        default=serializers.ItemSerializer
+    )
+    collection_serializer: Type[serializers.Serializer] = attr.ib(
+        default=serializers.CollectionSerializer
+    )
+
+    def create_item(self, model: stac_types.Item, **kwargs) -> stac_types.Item:
         """Create item."""
-        data = self.item_table.from_schema(model)
+        base_url = str(kwargs["request"].base_url)
+        data = self.item_serializer.stac_to_db(model)
         with self.session.writer.context_session() as session:
             session.add(data)
-            data.base_url = str(kwargs["request"].base_url)
-            return schemas.Item.from_orm(data)
+            return self.item_serializer.db_to_stac(data, base_url)
 
     def create_collection(
-        self, model: schemas.Collection, **kwargs
-    ) -> schemas.Collection:
+        self, model: stac_types.Collection, **kwargs
+    ) -> stac_types.Collection:
         """Create collection."""
-        data = self.collection_table.from_schema(model)
+        base_url = str(kwargs["request"].base_url)
+        data = self.collection_serializer.stac_to_db(model)
         with self.session.writer.context_session() as session:
             session.add(data)
-            data.base_url = str(kwargs["request"].base_url)
-            return schemas.Collection.from_orm(data)
+            return self.collection_serializer.db_to_stac(data, base_url=base_url)
 
-    def update_item(self, model: schemas.Item, **kwargs) -> schemas.Item:
+    def update_item(self, model: stac_types.Item, **kwargs) -> stac_types.Item:
         """Update item."""
+        base_url = str(kwargs["request"].base_url)
         with self.session.reader.context_session() as session:
             query = session.query(self.item_table).filter(
-                self.item_table.id == model.id
+                self.item_table.id == model["id"]
             )
             if not query.scalar():
-                raise NotFoundError(f"Item {model.id} not found")
-
-            data = self.item_table.get_database_model(model)
-
-            # SQLAlchemy orm updates don't like geoalchemy types
-            # Coerce to geojson instead
-            data["geometry"] = json.dumps(model.geometry.dict())
-            query.update(data)
+                raise NotFoundError(f"Item {model['id']} not found")
+            # SQLAlchemy orm updates don't seem to like geoalchemy types
+            db_model = self.item_serializer.stac_to_db(model)
+            query.update(self.item_serializer.row_to_dict(db_model))
+            stac_item = self.item_serializer.db_to_stac(db_model, base_url)
 
-            response = self.item_table.from_schema(model)
-            response.base_url = str(kwargs["request"].base_url)
-            return schemas.Item.from_orm(response)
-        return model
+            return stac_item
 
     def update_collection(
-        self, model: schemas.Collection, **kwargs
-    ) -> schemas.Collection:
+        self, model: stac_types.Collection, **kwargs
+    ) -> stac_types.Collection:
         """Update collection."""
+        base_url = str(kwargs["request"].base_url)
         with self.session.reader.context_session() as session:
             query = session.query(self.collection_table).filter(
-                self.collection_table.id == model.id
+                self.collection_table.id == model["id"]
             )
             if not query.scalar():
-                raise NotFoundError(f"Item {model.id} not found")
+                raise NotFoundError(f"Item {model['id']} not found")
+
             # SQLAlchemy orm updates don't seem to like geoalchemy types
-            data = self.collection_table.get_database_model(model)
-            data.pop("geometry", None)
-            query.update(data)
-        return model
+            db_model = self.collection_serializer.stac_to_db(model)
+            query.update(self.collection_serializer.row_to_dict(db_model))
+
+            return self.collection_serializer.db_to_stac(db_model, base_url)
 
-    def delete_item(self, item_id: str, collection_id: str, **kwargs) -> schemas.Item:
+    def delete_item(
+        self, item_id: str, collection_id: str, **kwargs
+    ) -> stac_types.Item:
         """Delete item."""
+        base_url = str(kwargs["request"].base_url)
         with self.session.writer.context_session() as session:
             query = session.query(self.item_table).filter(self.item_table.id == item_id)
             data = query.first()
             if not data:
-                raise NotFoundError(f"Item {id} not found")
+                raise NotFoundError(f"Item {item_id} not found")
             query.delete()
-            data.base_url = str(kwargs["request"].base_url)
-            return schemas.Item.from_orm(data)
+            return self.item_serializer.db_to_stac(data, base_url=base_url)
 
-    def delete_collection(self, id: str, **kwargs) -> schemas.Collection:
+    def delete_collection(self, id: str, **kwargs) -> stac_types.Collection:
         """Delete collection."""
+        base_url = str(kwargs["request"].base_url)
         with self.session.writer.context_session() as session:
             query = session.query(self.collection_table).filter(
                 self.collection_table.id == id
@@ -102,8 +110,7 @@ def delete_collection(self, id: str, **kwargs) -> schemas.Collection:
             if not data:
                 raise NotFoundError(f"Collection {id} not found")
             query.delete()
-            data.base_url = str(kwargs["request"].base_url)
-            return schemas.Collection.from_orm(data)
+            return self.collection_serializer.db_to_stac(data, base_url=base_url)
 
 
 @attr.s
@@ -118,26 +125,25 @@ def __attrs_post_init__(self):
         self.engine = self.session.writer.cached_engine
 
     @staticmethod
-    def _preprocess_item(item: schemas.Item) -> Dict:
+    def _preprocess_item(item: stac_types.Item) -> stac_types.Item:
         """Preprocess items to match data model.
 
         # TODO: dedup with GetterDict logic (ref #58)
         """
-        item = item.dict(exclude_none=True)
         item["geometry"] = json.dumps(item["geometry"])
         item["collection_id"] = item.pop("collection")
         item["datetime"] = item["properties"].pop("datetime")
         return item
 
     def bulk_item_insert(
-        self, items: schemas.Items, chunk_size: Optional[int] = None, **kwargs
+        self, items: Items, chunk_size: Optional[int] = None, **kwargs
     ) -> str:
         """Bulk item insertion using sqlalchemy core.
 
         https://docs.sqlalchemy.org/en/13/faq/performance.html#i-m-inserting-400-000-rows-with-the-orm-and-it-s-really-slow
         """
         # Use items.items because schemas.Items is a model with an items key
-        processed_items = [self._preprocess_item(item) for item in items.items]
+        processed_items = [self._preprocess_item(item) for item in items]
         return_msg = f"Successfully added {len(processed_items)} items."
         if chunk_size:
             for chunk in self._chunks(processed_items, chunk_size):
diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py
index 926760b05..ecf602a94 100644
--- a/stac_fastapi/types/setup.py
+++ b/stac_fastapi/types/setup.py
@@ -9,7 +9,7 @@
     "fastapi",
     "attrs",
     "pydantic[dotenv]",
-    "stac_pydantic==2.0.0",
+    "stac_pydantic==2.0.*",
 ]
 
 extra_reqs = {
diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py
index a20f4de0d..36cd20742 100644
--- a/stac_fastapi/types/stac_fastapi/types/config.py
+++ b/stac_fastapi/types/stac_fastapi/types/config.py
@@ -24,6 +24,7 @@ class ApiSettings(BaseSettings):
     app_host: str = "0.0.0.0"
     app_port: int = 8000
     reload: bool = True
+    enable_response_models: bool = False
 
     class Config:
         """model config (https://pydantic-docs.helpmanual.io/usage/model_config/)."""
diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py
index 364b7c4d5..4770eaafd 100644
--- a/stac_fastapi/types/stac_fastapi/types/core.py
+++ b/stac_fastapi/types/stac_fastapi/types/core.py
@@ -5,16 +5,16 @@
 from urllib.parse import urljoin
 
 import attr
-from stac_pydantic import Collection, Item, ItemCollection
-from stac_pydantic.api import ConformanceClasses, LandingPage, Search
-from stac_pydantic.links import Link, Relations
+from stac_pydantic.api import Search
+from stac_pydantic.links import Relations
 from stac_pydantic.shared import MimeTypes
 from stac_pydantic.version import STAC_VERSION
 
+from stac_fastapi.types import stac as stac_types
 from stac_fastapi.types.extension import ApiExtension
-from stac_fastapi.types.links import CollectionLinks
 
 NumType = Union[float, int]
+StacType = Dict[str, Any]
 
 
 @attr.s  # type:ignore
@@ -22,13 +22,13 @@ class BaseTransactionsClient(abc.ABC):
     """Defines a pattern for implementing the STAC transaction extension."""
 
     @abc.abstractmethod
-    def create_item(self, model: Item, **kwargs) -> Item:
+    def create_item(self, item: stac_types.Item, **kwargs) -> stac_types.Item:
         """Create a new item.
 
         Called with `POST /collections/{collectionId}/items`.
 
         Args:
-            model: the item
+            item: the item
 
         Returns:
             The item that was created.
@@ -37,7 +37,7 @@ def create_item(self, model: Item, **kwargs) -> Item:
         ...
 
     @abc.abstractmethod
-    def update_item(self, model: Item, **kwargs) -> Item:
+    def update_item(self, item: stac_types.Item, **kwargs) -> stac_types.Item:
         """Perform a complete update on an existing item.
 
         Called with `PUT /collections/{collectionId}/items`. It is expected that this item already exists.  The update
@@ -45,7 +45,7 @@ def update_item(self, model: Item, **kwargs) -> Item:
         by the transactions extension.
 
         Args:
-            model: the item (must be complete)
+            item: the item (must be complete)
 
         Returns:
             The updated item.
@@ -53,13 +53,16 @@ def update_item(self, model: Item, **kwargs) -> Item:
         ...
 
     @abc.abstractmethod
-    def delete_item(self, item_id: str, collection_id: str, **kwargs) -> Item:
+    def delete_item(
+        self, item_id: str, collection_id: str, **kwargs
+    ) -> stac_types.Item:
         """Delete an item from a collection.
 
         Called with `DELETE /collections/{collectionId}/items/{itemId}`
 
         Args:
-            id: id of the item.
+            item_id: id of the item.
+            collection_id: id of the collection.
 
         Returns:
             The deleted item.
@@ -67,13 +70,15 @@ def delete_item(self, item_id: str, collection_id: str, **kwargs) -> Item:
         ...
 
     @abc.abstractmethod
-    def create_collection(self, model: Collection, **kwargs) -> Collection:
+    def create_collection(
+        self, collection: stac_types.Collection, **kwargs
+    ) -> stac_types.Collection:
         """Create a new collection.
 
         Called with `POST /collections`.
 
         Args:
-            model: the collection
+            collection: the collection
 
         Returns:
             The collection that was created.
@@ -81,7 +86,9 @@ def create_collection(self, model: Collection, **kwargs) -> Collection:
         ...
 
     @abc.abstractmethod
-    def update_collection(self, model: Collection, **kwargs) -> Collection:
+    def update_collection(
+        self, collection: stac_types.Collection, **kwargs
+    ) -> stac_types.Collection:
         """Perform a complete update on an existing collection.
 
         Called with `PUT /collections`. It is expected that this item already exists.  The update should do a diff
@@ -89,7 +96,7 @@ def update_collection(self, model: Collection, **kwargs) -> Collection:
         transactions extension.
 
         Args:
-            model: the collection (must be complete)
+            collection: the collection (must be complete)
 
         Returns:
             The updated collection.
@@ -97,13 +104,13 @@ def update_collection(self, model: Collection, **kwargs) -> Collection:
         ...
 
     @abc.abstractmethod
-    def delete_collection(self, id: str, **kwargs) -> Collection:
+    def delete_collection(self, collection_id: str, **kwargs) -> stac_types.Collection:
         """Delete a collection.
 
         Called with `DELETE /collections/{collectionId}`
 
         Args:
-            id: id of the collection.
+            collection_id: id of the collection.
 
         Returns:
             The deleted collection.
@@ -112,24 +119,178 @@ def delete_collection(self, id: str, **kwargs) -> Collection:
 
 
 @attr.s  # type:ignore
-class BaseCoreClient(abc.ABC):
-    """Defines a pattern for implementing STAC api core endpoints.
+class AsyncBaseTransactionsClient(abc.ABC):
+    """Defines a pattern for implementing the STAC transaction extension."""
 
-    Attributes:
-        extensions: list of registered api extensions.
-    """
+    @abc.abstractmethod
+    async def create_item(self, item: stac_types.Item, **kwargs) -> stac_types.Item:
+        """Create a new item.
+
+        Called with `POST /collections/{collectionId}/items`.
+
+        Args:
+            item: the item
+
+        Returns:
+            The item that was created.
+
+        """
+        ...
+
+    @abc.abstractmethod
+    async def update_item(self, item: stac_types.Item, **kwargs) -> stac_types.Item:
+        """Perform a complete update on an existing item.
+
+        Called with `PUT /collections/{collectionId}/items`. It is expected that this item already exists.  The update
+        should do a diff against the saved item and perform any necessary updates.  Partial updates are not supported
+        by the transactions extension.
+
+        Args:
+            item: the item (must be complete)
+
+        Returns:
+            The updated item.
+        """
+        ...
+
+    @abc.abstractmethod
+    async def delete_item(
+        self, item_id: str, collection_id: str, **kwargs
+    ) -> stac_types.Item:
+        """Delete an item from a collection.
+
+        Called with `DELETE /collections/{collectionId}/items/{itemId}`
+
+        Args:
+            item_id: id of the item.
+            collection_id: id of the collection.
+
+        Returns:
+            The deleted item.
+        """
+        ...
+
+    @abc.abstractmethod
+    async def create_collection(
+        self, collection: stac_types.Collection, **kwargs
+    ) -> stac_types.Collection:
+        """Create a new collection.
+
+        Called with `POST /collections`.
+
+        Args:
+            collection: the collection
+
+        Returns:
+            The collection that was created.
+        """
+        ...
+
+    @abc.abstractmethod
+    async def update_collection(
+        self, collection: stac_types.Collection, **kwargs
+    ) -> stac_types.Collection:
+        """Perform a complete update on an existing collection.
+
+        Called with `PUT /collections`. It is expected that this item already exists.  The update should do a diff
+        against the saved collection and perform any necessary updates.  Partial updates are not supported by the
+        transactions extension.
+
+        Args:
+            collection: the collection (must be complete)
+
+        Returns:
+            The updated collection.
+        """
+        ...
+
+    @abc.abstractmethod
+    async def delete_collection(
+        self, collection_id: str, **kwargs
+    ) -> stac_types.Collection:
+        """Delete a collection.
+
+        Called with `DELETE /collections/{collectionId}`
+
+        Args:
+            collection_id: id of the collection.
+
+        Returns:
+            The deleted collection.
+        """
+        ...
+
+
+@attr.s
+class LandingPageMixin:
+    """Create a STAC landing page (GET /)."""
 
     stac_version: str = attr.ib(default=STAC_VERSION)
     landing_page_id: str = attr.ib(default="stac-fastapi")
     title: str = attr.ib(default="stac-fastapi")
     description: str = attr.ib(default="stac-fastapi")
+
+    def _landing_page(self, base_url: str) -> stac_types.LandingPage:
+        landing_page = stac_types.LandingPage(
+            type="Catalog",
+            id=self.landing_page_id,
+            title=self.title,
+            description=self.description,
+            stac_version=self.stac_version,
+            conformsTo=[
+                "https://stacspec.org/STAC-api.html",
+                "http://docs.opengeospatial.org/is/17-069r3/17-069r3.html#ats_geojson",
+            ],
+            links=[
+                {
+                    "rel": Relations.self.value,
+                    "type": MimeTypes.json,
+                    "href": base_url,
+                },
+                {
+                    "rel": "data",
+                    "type": MimeTypes.json,
+                    "href": urljoin(base_url, "collections"),
+                },
+                {
+                    "rel": Relations.docs.value,
+                    "type": MimeTypes.json,
+                    "title": "OpenAPI docs",
+                    "href": urljoin(base_url, "docs"),
+                },
+                {
+                    "rel": Relations.conformance.value,
+                    "type": MimeTypes.json,
+                    "title": "STAC/WFS3 conformance classes implemented by this server",
+                    "href": base_url,
+                },
+                {
+                    "rel": Relations.search.value,
+                    "type": MimeTypes.json,
+                    "title": "STAC search",
+                    "href": urljoin(base_url, "search"),
+                },
+            ],
+            stac_extensions=[],
+        )
+        return landing_page
+
+
+@attr.s  # type:ignore
+class BaseCoreClient(LandingPageMixin, abc.ABC):
+    """Defines a pattern for implementing STAC api core endpoints.
+
+    Attributes:
+        extensions: list of registered api extensions.
+    """
+
     extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list))
 
     def extension_is_enabled(self, extension: Type[ApiExtension]) -> bool:
         """Check if an api extension is enabled."""
         return any([isinstance(ext, extension) for ext in self.extensions])
 
-    def landing_page(self, **kwargs) -> LandingPage:
+    def landing_page(self, **kwargs) -> stac_types.LandingPage:
         """Landing page.
 
         Called with `GET /`.
@@ -138,58 +299,21 @@ def landing_page(self, **kwargs) -> LandingPage:
             API landing page, serving as an entry point to the API.
         """
         base_url = str(kwargs["request"].base_url)
-        landing_page = LandingPage(
-            id=self.landing_page_id,
-            title=self.title,
-            description=self.description,
-            conformsTo=[
-                "https://stacspec.org/STAC-api.html",
-                "http://docs.opengeospatial.org/is/17-069r3/17-069r3.html#ats_geojson",
-            ],
-            stac_version=self.stac_version,
-            links=[
-                Link(
-                    rel=Relations.self,
-                    type=MimeTypes.json,
-                    href=base_url,
-                ),
-                Link(
-                    rel="data",
-                    type=MimeTypes.json,
-                    href=urljoin(base_url, "collections"),
-                ),
-                Link(
-                    rel=Relations.docs,
-                    type=MimeTypes.html,
-                    title="OpenAPI docs",
-                    href=urljoin(str(base_url), "docs"),
-                ),
-                Link(
-                    rel=Relations.conformance,
-                    type=MimeTypes.json,
-                    title="STAC/WFS3 conformance classes implemented by this server",
-                    href=urljoin(str(base_url), "conformance"),
-                ),
-                Link(
-                    rel=Relations.search,
-                    type=MimeTypes.geojson,
-                    title="STAC search",
-                    href=urljoin(str(base_url), "search"),
-                ),
-            ],
-        )
+        landing_page = self._landing_page(base_url=base_url)
         collections = self.all_collections(request=kwargs["request"])
-        for coll in collections:
-            coll_link = CollectionLinks(
-                collection_id=coll.id, base_url=str(base_url)
-            ).self()
-            coll_link.rel = Relations.child
-            coll_link.title = coll.title
-            landing_page.links.append(coll_link)
+        for collection in collections:
+            landing_page["links"].append(
+                {
+                    "rel": Relations.child.value,
+                    "type": MimeTypes.json.value,
+                    "title": collection["title"],
+                    "href": urljoin(base_url, f"collections/{collection['id']}"),
+                }
+            )
         return landing_page
 
     @abc.abstractmethod
-    def conformance(self, **kwargs) -> ConformanceClasses:
+    def conformance(self, **kwargs) -> stac_types.Conformance:
         """Conformance classes.
 
         Called with `GET /conformance`.
@@ -200,7 +324,9 @@ def conformance(self, **kwargs) -> ConformanceClasses:
         ...
 
     @abc.abstractmethod
-    def post_search(self, search_request: Search, **kwargs) -> Dict[str, Any]:
+    def post_search(
+        self, search_request: Search, **kwargs
+    ) -> stac_types.ItemCollection:
         """Cross catalog search (POST).
 
         Called with `POST /search`.
@@ -225,8 +351,8 @@ def get_search(
         token: Optional[str] = None,
         fields: Optional[List[str]] = None,
         sortby: Optional[str] = None,
-        **kwargs
-    ) -> Dict[str, Any]:
+        **kwargs,
+    ) -> stac_types.ItemCollection:
         """Cross catalog search (GET).
 
         Called with `GET /search`.
@@ -237,13 +363,14 @@ def get_search(
         ...
 
     @abc.abstractmethod
-    def get_item(self, item_id: str, collection_id: str, **kwargs) -> Item:
+    def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Item:
         """Get item by id.
 
         Called with `GET /collections/{collectionId}/items/{itemId}`.
 
         Args:
-            id: Id of the item.
+            item_id: Id of the item.
+            collection_id: Id of the collection.
 
         Returns:
             Item.
@@ -251,7 +378,7 @@ def get_item(self, item_id: str, collection_id: str, **kwargs) -> Item:
         ...
 
     @abc.abstractmethod
-    def all_collections(self, **kwargs) -> List[Collection]:
+    def all_collections(self, **kwargs) -> List[stac_types.Collection]:
         """Get all available collections.
 
         Called with `GET /collections`.
@@ -262,13 +389,13 @@ def all_collections(self, **kwargs) -> List[Collection]:
         ...
 
     @abc.abstractmethod
-    def get_collection(self, id: str, **kwargs) -> Collection:
+    def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection:
         """Get collection by id.
 
         Called with `GET /collections/{collectionId}`.
 
         Args:
-            id: Id of the collection.
+            collection_id: Id of the collection.
 
         Returns:
             Collection.
@@ -277,14 +404,163 @@ def get_collection(self, id: str, **kwargs) -> Collection:
 
     @abc.abstractmethod
     def item_collection(
-        self, id: str, limit: int = 10, token: str = None, **kwargs
-    ) -> ItemCollection:
+        self, collection_id: str, limit: int = 10, token: str = None, **kwargs
+    ) -> stac_types.ItemCollection:
+        """Get all items from a specific collection.
+
+        Called with `GET /collections/{collectionId}/items`
+
+        Args:
+            collection_id: id of the collection.
+            limit: number of items to return.
+            token: pagination token.
+
+        Returns:
+            An ItemCollection.
+        """
+        ...
+
+
+@attr.s  # type:ignore
+class AsyncBaseCoreClient(LandingPageMixin, abc.ABC):
+    """Defines a pattern for implementing STAC api core endpoints.
+
+    Attributes:
+        extensions: list of registered api extensions.
+    """
+
+    extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list))
+
+    def extension_is_enabled(self, extension: Type[ApiExtension]) -> bool:
+        """Check if an api extension is enabled."""
+        return any([isinstance(ext, extension) for ext in self.extensions])
+
+    async def landing_page(self, **kwargs) -> stac_types.LandingPage:
+        """Landing page.
+
+        Called with `GET /`.
+
+        Returns:
+            API landing page, serving as an entry point to the API.
+        """
+        base_url = str(kwargs["request"].base_url)
+        landing_page = self._landing_page(base_url=base_url)
+        collections = await self.all_collections(request=kwargs["request"])
+        for collection in collections:
+            landing_page["links"].append(
+                {
+                    "rel": Relations.child.value,
+                    "type": MimeTypes.json.value,
+                    "title": collection["title"],
+                    "href": urljoin(base_url, f"collections/{collection['id']}"),
+                }
+            )
+        return landing_page
+
+    @abc.abstractmethod
+    async def conformance(self, **kwargs) -> stac_types.Conformance:
+        """Conformance classes.
+
+        Called with `GET /conformance`.
+
+        Returns:
+            Conformance classes which the server conforms to.
+        """
+        ...
+
+    @abc.abstractmethod
+    async def post_search(
+        self, search_request: Search, **kwargs
+    ) -> stac_types.ItemCollection:
+        """Cross catalog search (POST).
+
+        Called with `POST /search`.
+
+        Args:
+            search_request: search request parameters.
+
+        Returns:
+            ItemCollection containing items which match the search criteria.
+        """
+        ...
+
+    @abc.abstractmethod
+    async def get_search(
+        self,
+        collections: Optional[List[str]] = None,
+        ids: Optional[List[str]] = None,
+        bbox: Optional[List[NumType]] = None,
+        datetime: Optional[Union[str, datetime]] = None,
+        limit: Optional[int] = 10,
+        query: Optional[str] = None,
+        token: Optional[str] = None,
+        fields: Optional[List[str]] = None,
+        sortby: Optional[str] = None,
+        **kwargs,
+    ) -> stac_types.ItemCollection:
+        """Cross catalog search (GET).
+
+        Called with `GET /search`.
+
+        Returns:
+            ItemCollection containing items which match the search criteria.
+        """
+        ...
+
+    @abc.abstractmethod
+    async def get_item(
+        self, item_id: str, collection_id: str, **kwargs
+    ) -> stac_types.Item:
+        """Get item by id.
+
+        Called with `GET /collections/{collectionId}/items/{itemId}`.
+
+        Args:
+            item_id: Id of the item.
+            collection_id: Id of the collection.
+
+        Returns:
+            Item.
+        """
+        ...
+
+    @abc.abstractmethod
+    async def all_collections(self, **kwargs) -> List[stac_types.Collection]:
+        """Get all available collections.
+
+        Called with `GET /collections`.
+
+        Returns:
+            A list of collections.
+        """
+        ...
+
+    @abc.abstractmethod
+    async def get_collection(
+        self, collection_id: str, **kwargs
+    ) -> stac_types.Collection:
+        """Get collection by id.
+
+        Called with `GET /collections/{collectionId}`.
+
+        Args:
+            collection_id: Id of the collection.
+
+        Returns:
+            Collection.
+        """
+        ...
+
+    @abc.abstractmethod
+    async def item_collection(
+        self, collection_id: str, limit: int = 10, token: str = None, **kwargs
+    ) -> stac_types.ItemCollection:
         """Get all items from a specific collection.
 
         Called with `GET /collections/{collectionId}/items`
 
         Args:
-            id: id of the collection.
+            collection_id: id of the collection.
             limit: number of items to return.
             token: pagination token.
 
diff --git a/stac_fastapi/types/stac_fastapi/types/links.py b/stac_fastapi/types/stac_fastapi/types/links.py
index 9ab9e26aa..7452781cc 100644
--- a/stac_fastapi/types/stac_fastapi/types/links.py
+++ b/stac_fastapi/types/stac_fastapi/types/links.py
@@ -1,10 +1,10 @@
 """link helpers."""
 
-from typing import Dict, List
+from typing import Any, Dict, List
 from urllib.parse import urljoin
 
 import attr
-from stac_pydantic.links import Link, Relations
+from stac_pydantic.links import Relations
 from stac_pydantic.shared import MimeTypes
 
 # These can be inferred from the item/collection so they aren't included in the database
@@ -17,6 +17,14 @@ def filter_links(links: List[Dict]) -> List[Dict]:
     return [link for link in links if link["rel"] not in INFERRED_LINK_RELS]
 
 
+def resolve_links(links: list, base_url: str) -> List[Dict]:
+    """Convert relative links to absolute links."""
+    filtered_links = filter_links(links)
+    for link in filtered_links:
+        link.update({"href": urljoin(base_url, link["href"])})
+    return filtered_links
+
+
 @attr.s
 class BaseLinks:
     """Create inferred links common to collections and items."""
@@ -24,36 +32,36 @@ class BaseLinks:
     collection_id: str = attr.ib()
     base_url: str = attr.ib()
 
-    def root(self) -> Link:
+    def root(self) -> Dict[str, Any]:
         """Return the catalog root."""
-        return Link(rel=Relations.root, type=MimeTypes.json, href=self.base_url)
+        return dict(rel=Relations.root, type=MimeTypes.json, href=self.base_url)
 
 
 @attr.s
 class CollectionLinks(BaseLinks):
     """Create inferred links specific to collections."""
 
-    def self(self) -> Link:
+    def self(self) -> Dict[str, Any]:
         """Create the `self` link."""
-        return Link(
+        return dict(
             rel=Relations.self,
             type=MimeTypes.json,
             href=urljoin(self.base_url, f"collections/{self.collection_id}"),
         )
 
-    def parent(self) -> Link:
+    def parent(self) -> Dict[str, Any]:
         """Create the `parent` link."""
-        return Link(rel=Relations.parent, type=MimeTypes.json, href=self.base_url)
+        return dict(rel=Relations.parent, type=MimeTypes.json, href=self.base_url)
 
-    def items(self) -> Link:
+    def items(self) -> Dict[str, Any]:
         """Create the `items` link."""
-        return Link(
+        return dict(
             rel="items",
             type=MimeTypes.geojson,
             href=urljoin(self.base_url, f"collections/{self.collection_id}/items"),
         )
 
-    def create_links(self) -> List[Link]:
+    def create_links(self) -> List[Dict[str, Any]]:
         """Return all inferred links."""
         return [self.self(), self.parent(), self.items(), self.root()]
 
@@ -64,9 +72,9 @@ class ItemLinks(BaseLinks):
 
     item_id: str = attr.ib()
 
-    def self(self) -> Link:
+    def self(self) -> Dict[str, Any]:
         """Create the `self` link."""
-        return Link(
+        return dict(
             rel=Relations.self,
             type=MimeTypes.geojson,
             href=urljoin(
@@ -75,25 +83,25 @@ def self(self) -> Link:
             ),
         )
 
-    def parent(self) -> Link:
+    def parent(self) -> Dict[str, Any]:
         """Create the `parent` link."""
-        return Link(
+        return dict(
             rel=Relations.parent,
             type=MimeTypes.json,
             href=urljoin(self.base_url, f"collections/{self.collection_id}"),
         )
 
-    def collection(self) -> Link:
+    def collection(self) -> Dict[str, Any]:
         """Create the `collection` link."""
-        return Link(
+        return dict(
             rel=Relations.collection,
             type=MimeTypes.json,
             href=urljoin(self.base_url, f"collections/{self.collection_id}"),
         )
 
-    def tiles(self) -> Link:
+    def tiles(self) -> Dict[str, Any]:
         """Create the `tiles` link."""
-        return Link(
+        return dict(
             rel=Relations.alternate,
             type=MimeTypes.json,
             title="tiles",
@@ -103,7 +111,7 @@ def tiles(self) -> Link:
             ),
         )
 
-    def create_links(self) -> List[Link]:
+    def create_links(self) -> List[Dict[str, Any]]:
         """Return all inferred links."""
         links = [
             self.self(),
diff --git a/stac_fastapi/types/stac_fastapi/types/stac.py b/stac_fastapi/types/stac_fastapi/types/stac.py
new file mode 100644
index 000000000..875112e4e
--- /dev/null
+++ b/stac_fastapi/types/stac_fastapi/types/stac.py
@@ -0,0 +1,72 @@
+"""STAC types."""
+from typing import Any, Dict, List, Optional, TypedDict, Union
+
+NumType = Union[float, int]
+
+
+class LandingPage(TypedDict, total=False):
+    """STAC Landing Page."""
+
+    type: str
+    stac_version: str
+    stac_extensions: Optional[List[str]]
+    id: str
+    title: str
+    description: str
+    conformsTo: List[str]
+    links: List[Dict[str, Any]]
+
+
+class Conformance(TypedDict):
+    """STAC Conformance Classes."""
+
+    conformsTo: List[str]
+
+
+class Catalog(TypedDict, total=False):
+    """STAC Catalog."""
+
+    type: str
+    stac_version: str
+    stac_extensions: Optional[List[str]]
+    id: str
+    title: Optional[str]
+    description: str
+    links: List[Dict[str, Any]]
+
+
+class Collection(Catalog, total=False):
+    """STAC Collection."""
+
+    keywords: List[str]
+    license: str
+    providers: List[Dict[str, Any]]
+    extent: Dict[str, Any]
+    summaries: Dict[str, Any]
+    assets: Dict[str, Any]
+
+
+class Item(TypedDict, total=False):
+    """STAC Item."""
+
+    type: str
+    stac_version: str
+    stac_extensions: Optional[List[str]]
+    id: str
+    geometry: Dict[str, Any]
+    bbox: List[NumType]
+    properties: Dict[str, Any]
+    links: List[Dict[str, Any]]
+    assets: Dict[str, Any]
+    collection: str
+
+
+class ItemCollection(TypedDict, total=False):
+    """STAC Item Collection."""
+
+    type: str
+    stac_version: str
+    stac_extensions: Optional[List[str]]
+    features: List[Item]
+    links: List[Dict[str, Any]]
+    context: Optional[Dict[str, int]]


Test patch:
diff --git a/stac_fastapi/pgstac/tests/clients/test_postgres.py b/stac_fastapi/pgstac/tests/clients/test_postgres.py
index 990410c17..368c1392f 100644
--- a/stac_fastapi/pgstac/tests/clients/test_postgres.py
+++ b/stac_fastapi/pgstac/tests/clients/test_postgres.py
@@ -58,7 +58,7 @@ async def test_create_item(app_client, load_test_data: Callable, load_test_colle
     in_json = load_test_data("test_item.json")
     in_item = Item.parse_obj(in_json)
     resp = await app_client.post(
-        "/collections/{coll.id}/items",
+        f"/collections/{coll.id}/items",
         json=in_json,
     )
     assert resp.status_code == 200
diff --git a/stac_fastapi/pgstac/tests/conftest.py b/stac_fastapi/pgstac/tests/conftest.py
index 0a899a33f..9f52f2424 100644
--- a/stac_fastapi/pgstac/tests/conftest.py
+++ b/stac_fastapi/pgstac/tests/conftest.py
@@ -6,6 +6,7 @@
 
 import asyncpg
 import pytest
+from fastapi.responses import ORJSONResponse
 from httpx import AsyncClient
 from pypgstac import pypgstac
 from stac_pydantic import Collection, Item
@@ -84,13 +85,14 @@ def api_client(pg):
     api = StacApi(
         settings=settings,
         extensions=[
-            TransactionExtension(client=TransactionsClient),
+            TransactionExtension(client=TransactionsClient(), settings=settings),
             QueryExtension(),
             SortExtension(),
             FieldsExtension(),
         ],
         client=CoreCrudClient(),
         search_request_model=PgstacSearch,
+        response_class=ORJSONResponse,
     )
 
     return api
diff --git a/stac_fastapi/pgstac/tests/data/test_collection.json b/stac_fastapi/pgstac/tests/data/test_collection.json
index 04d309cb6..8537857f1 100644
--- a/stac_fastapi/pgstac/tests/data/test_collection.json
+++ b/stac_fastapi/pgstac/tests/data/test_collection.json
@@ -1,7 +1,7 @@
 {
   "id": "test-collection",
   "stac_extensions": [],
-  "type": "collection",
+  "type": "Collection",
   "description": "Landat 8 imagery radiometrically calibrated and orthorectified using gound points and Digital Elevation Model (DEM) data to correct relief displacement.",
   "stac_version": "1.0.0",
   "license": "PDDL-1.0",
diff --git a/stac_fastapi/pgstac/tests/data/test_item.json b/stac_fastapi/pgstac/tests/data/test_item.json
index 02edd0ec2..2b7fdd860 100644
--- a/stac_fastapi/pgstac/tests/data/test_item.json
+++ b/stac_fastapi/pgstac/tests/data/test_item.json
@@ -1,6 +1,7 @@
 {
   "type": "Feature",
   "id": "test-item",
+  "stac_version": "1.0.0",
   "stac_extensions": [
     "https://stac-extensions.github.io/eo/v1.0.0/schema.json",
     "https://stac-extensions.github.io/projection/v1.0.0/schema.json"
@@ -120,7 +121,7 @@
     "view:sun_azimuth": -148.83296771,
     "instrument": "OLI_TIRS",
     "landsat:product_id": "LC08_L1GT_208161_20200212_20200212_01_RT",
-    "eo:cloud_cover": -1,
+    "eo:cloud_cover": 0,
     "landsat:tier": "RT",
     "landsat:processing_level": "L1GT",
     "landsat:column": "208",
@@ -139,101 +140,344 @@
   ],
   "collection": "test-collection",
   "assets": {
-    "B1": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 1 (coastal)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B1.TIF",
-      "description": "this is a description"
-    },
-    "B2": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 2 (blue)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B2.TIF",
-      "description": "this is a description"
+    "ANG": {
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ANG.txt",
+      "type": "text/plain",
+      "title": "Angle Coefficients File",
+      "description": "Collection 2 Level-1 Angle Coefficients File (ANG)"
     },
-    "B3": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 3 (green)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B3.TIF",
-      "description": "this is a description"
+    "SR_B1": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B1.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Coastal/Aerosol Band (B1)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B1",
+          "common_name": "coastal",
+          "center_wavelength": 0.44,
+          "full_width_half_max": 0.02
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Coastal/Aerosol Band (B1) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B4": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 4 (red)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B4.TIF",
-      "description": "this is a description"
+    "SR_B2": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B2.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Blue Band (B2)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B2",
+          "common_name": "blue",
+          "center_wavelength": 0.48,
+          "full_width_half_max": 0.06
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Blue Band (B2) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B5": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 5 (nir)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B5.TIF",
-      "description": "this is a description"
+    "SR_B3": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B3.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Green Band (B3)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B3",
+          "common_name": "green",
+          "center_wavelength": 0.56,
+          "full_width_half_max": 0.06
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Green Band (B3) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B6": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 6 (swir16)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B6.TIF",
-      "description": "this is a description"
+    "SR_B4": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B4.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Red Band (B4)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B4",
+          "common_name": "red",
+          "center_wavelength": 0.65,
+          "full_width_half_max": 0.04
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Red Band (B4) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B7": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 7 (swir22)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B7.TIF",
-      "description": "this is a description"
+    "SR_B5": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B5.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Near Infrared Band 0.8 (B5)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B5",
+          "common_name": "nir08",
+          "center_wavelength": 0.86,
+          "full_width_half_max": 0.03
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Near Infrared Band 0.8 (B5) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B8": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 8 (pan)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B8.TIF",
-      "description": "this is a description"
+    "SR_B6": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B6.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Short-wave Infrared Band 1.6 (B6)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B6",
+          "common_name": "swir16",
+          "center_wavelength": 1.6,
+          "full_width_half_max": 0.08
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Short-wave Infrared Band 1.6 (B6) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B9": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 9 (cirrus)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B9.TIF",
-      "description": "this is a description"
+    "SR_B7": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B7.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Short-wave Infrared Band 2.2 (B7)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B7",
+          "common_name": "swir22",
+          "center_wavelength": 2.2,
+          "full_width_half_max": 0.2
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Short-wave Infrared Band 2.2 (B7) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "ANG": {
-      "type": "text/plain",
-      "title": "Angle coefficients file",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_ANG.txt",
-      "description": "this is a description"
+    "ST_QA": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_QA.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Surface Temperature Quality Assessment Band",
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Landsat Collection 2 Level-2 Surface Temperature Band Surface Temperature Product",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B10": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 10 (lwir)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B10.TIF",
-      "description": "this is a description"
+    "ST_B10": {
+      "gsd": 100,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_B10.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Surface Temperature Band (B10)",
+      "eo:bands": [
+        {
+          "gsd": 100,
+          "name": "ST_B10",
+          "common_name": "lwir11",
+          "center_wavelength": 10.9,
+          "full_width_half_max": 0.8
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Landsat Collection 2 Level-2 Surface Temperature Band (B10) Surface Temperature Product",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B11": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 11 (lwir)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B11.TIF",
-      "description": "this is a description"
+    "MTL.txt": {
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_MTL.txt",
+      "type": "text/plain",
+      "title": "Product Metadata File",
+      "description": "Collection 2 Level-1 Product Metadata File (MTL)"
     },
-    "BQA": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band quality data",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_BQA.TIF",
-      "description": "this is a description"
+    "MTL.xml": {
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_MTL.xml",
+      "type": "application/xml",
+      "title": "Product Metadata File (xml)",
+      "description": "Collection 2 Level-1 Product Metadata File (xml)"
     },
-    "MTL": {
-      "type": "text/plain",
-      "title": "original metadata file",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_MTL.txt",
-      "description": "this is a description"
+    "ST_DRAD": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_DRAD.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Downwelled Radiance Band",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "ST_DRAD",
+          "description": "downwelled radiance"
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Landsat Collection 2 Level-2 Downwelled Radiance Band Surface Temperature Product",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "index": {
-      "type": "text/html",
-      "title": "HTML index page",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_MTL.txt",
-      "description": "this is a description"
+    "ST_EMIS": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_EMIS.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Emissivity Band",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "ST_EMIS",
+          "description": "emissivity"
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Landsat Collection 2 Level-2 Emissivity Band Surface Temperature Product",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "thumbnail": {
-      "type": "image/jpeg",
-      "title": "Thumbnail image",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_thumb_large.jpg",
-      "description": "this is a description"
+    "ST_EMSD": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_EMSD.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Emissivity Standard Deviation Band",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "ST_EMSD",
+          "description": "emissivity standard deviation"
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Landsat Collection 2 Level-2 Emissivity Standard Deviation Band Surface Temperature Product",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     }
   },
   "links": [
diff --git a/stac_fastapi/pgstac/tests/resources/test_collection.py b/stac_fastapi/pgstac/tests/resources/test_collection.py
index 66c594d15..e0fa6281e 100644
--- a/stac_fastapi/pgstac/tests/resources/test_collection.py
+++ b/stac_fastapi/pgstac/tests/resources/test_collection.py
@@ -1,5 +1,6 @@
 from typing import Callable
 
+import pystac
 import pytest
 from stac_pydantic import Collection
 
@@ -90,3 +91,27 @@ async def test_nocollections(
 ):
     resp = await app_client.get("/collections")
     assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_returns_valid_collection(app_client, load_test_data):
+    """Test updating a collection which already exists"""
+    in_json = load_test_data("test_collection.json")
+    resp = await app_client.post(
+        "/collections",
+        json=in_json,
+    )
+    assert resp.status_code == 200
+
+    resp = await app_client.get(f"/collections/{in_json['id']}")
+    assert resp.status_code == 200
+    resp_json = resp.json()
+
+    # Mock root to allow validation
+    mock_root = pystac.Catalog(
+        id="test", description="test desc", href="https://example.com"
+    )
+    collection = pystac.Collection.from_dict(
+        resp_json, root=mock_root, preserve_dict=False
+    )
+    collection.validate()
diff --git a/stac_fastapi/pgstac/tests/resources/test_item.py b/stac_fastapi/pgstac/tests/resources/test_item.py
index 74ef88851..aa86da5a1 100644
--- a/stac_fastapi/pgstac/tests/resources/test_item.py
+++ b/stac_fastapi/pgstac/tests/resources/test_item.py
@@ -4,6 +4,7 @@
 from typing import Callable
 from urllib.parse import parse_qs, urlparse
 
+import pystac
 import pytest
 from shapely.geometry import Polygon
 from stac_pydantic import Collection, Item
@@ -78,6 +79,35 @@ async def test_create_item(app_client, load_test_data: Callable, load_test_colle
     assert in_item.dict(exclude={"links"}) == get_item.dict(exclude={"links"})
 
 
+@pytest.mark.asyncio
+async def test_fetches_valid_item(
+    app_client, load_test_data: Callable, load_test_collection
+):
+    coll = load_test_collection
+
+    in_json = load_test_data("test_item.json")
+    in_item = Item.parse_obj(in_json)
+    resp = await app_client.post(
+        "/collections/{coll.id}/items",
+        json=in_json,
+    )
+    assert resp.status_code == 200
+
+    post_item = Item.parse_obj(resp.json())
+    assert in_item.dict(exclude={"links"}) == post_item.dict(exclude={"links"})
+
+    resp = await app_client.get(f"/collections/{coll.id}/items/{post_item.id}")
+
+    assert resp.status_code == 200
+    item_dict = resp.json()
+    # Mock root to allow validation
+    mock_root = pystac.Catalog(
+        id="test", description="test desc", href="https://example.com"
+    )
+    item = pystac.Item.from_dict(item_dict, preserve_dict=False, root=mock_root)
+    item.validate()
+
+
 @pytest.mark.asyncio
 async def test_update_item(
     app_client, load_test_data: Callable, load_test_collection, load_test_item
@@ -774,7 +804,7 @@ async def test_field_extension_get(app_client, load_test_data, load_test_collect
     )
     assert resp.status_code == 200
 
-    params = {"fields": "+properties.proj:epsg,+properties.gsd"}
+    params = {"fields": "+properties.proj:epsg,+properties.gsd,+collection"}
     resp = await app_client.get("/search", params=params)
     feat_properties = resp.json()["features"][0]["properties"]
     assert not set(feat_properties) - {"proj:epsg", "gsd", "datetime"}
@@ -796,6 +826,7 @@ async def test_field_extension_post(app_client, load_test_data, load_test_collec
                 "properties.eo:cloud_cover",
                 "properties.orientation",
                 "assets",
+                "collection",
             ],
         }
     }
@@ -825,7 +856,7 @@ async def test_field_extension_exclude_and_include(
     body = {
         "fields": {
             "exclude": ["properties.eo:cloud_cover"],
-            "include": ["properties.eo:cloud_cover"],
+            "include": ["properties.eo:cloud_cover", "collection"],
         }
     }
 
diff --git a/stac_fastapi/sqlalchemy/tests/api/test_api.py b/stac_fastapi/sqlalchemy/tests/api/test_api.py
index 4eab6256a..cd07f4500 100644
--- a/stac_fastapi/sqlalchemy/tests/api/test_api.py
+++ b/stac_fastapi/sqlalchemy/tests/api/test_api.py
@@ -1,7 +1,5 @@
 from datetime import datetime, timedelta
 
-from stac_pydantic import Item
-
 from ..conftest import MockStarletteRequest
 
 STAC_CORE_ROUTES = [
@@ -48,7 +46,7 @@ def test_app_transaction_extension(app_client, load_test_data):
 
 
 def test_app_context_extension(load_test_data, app_client, postgres_transactions):
-    item = Item.parse_obj(load_test_data("test_item.json"))
+    item = load_test_data("test_item.json")
     postgres_transactions.create_item(item, request=MockStarletteRequest)
 
     resp = app_client.get("/search", params={"collections": ["test-collection"]})
@@ -59,7 +57,7 @@ def test_app_context_extension(load_test_data, app_client, postgres_transactions
 
 
 def test_app_fields_extension(load_test_data, app_client, postgres_transactions):
-    item = Item.parse_obj(load_test_data("test_item.json"))
+    item = load_test_data("test_item.json")
     postgres_transactions.create_item(item, request=MockStarletteRequest)
 
     resp = app_client.get("/search", params={"collections": ["test-collection"]})
@@ -70,8 +68,7 @@ def test_app_fields_extension(load_test_data, app_client, postgres_transactions)
 
 def test_app_query_extension(load_test_data, app_client, postgres_transactions):
     test_item = load_test_data("test_item.json")
-    item = Item.parse_obj(test_item)
-    postgres_transactions.create_item(item, request=MockStarletteRequest)
+    postgres_transactions.create_item(test_item, request=MockStarletteRequest)
 
     params = {"query": {"proj:epsg": {"gt": test_item["properties"]["proj:epsg"] + 1}}}
     resp = app_client.post("/search", json=params)
@@ -85,9 +82,7 @@ def test_app_sort_extension(load_test_data, app_client, postgres_transactions):
     item_date = datetime.strptime(
         first_item["properties"]["datetime"], "%Y-%m-%dT%H:%M:%SZ"
     )
-    postgres_transactions.create_item(
-        Item.parse_obj(first_item), request=MockStarletteRequest
-    )
+    postgres_transactions.create_item(first_item, request=MockStarletteRequest)
 
     second_item = load_test_data("test_item.json")
     second_item["id"] = "another-item"
@@ -95,9 +90,7 @@ def test_app_sort_extension(load_test_data, app_client, postgres_transactions):
     second_item["properties"]["datetime"] = another_item_date.strftime(
         "%Y-%m-%dT%H:%M:%SZ"
     )
-    postgres_transactions.create_item(
-        Item.parse_obj(second_item), request=MockStarletteRequest
-    )
+    postgres_transactions.create_item(second_item, request=MockStarletteRequest)
 
     params = {
         "collections": [first_item["collection"]],
diff --git a/stac_fastapi/sqlalchemy/tests/clients/test_postgres.py b/stac_fastapi/sqlalchemy/tests/clients/test_postgres.py
index ad82575e3..2da89222e 100644
--- a/stac_fastapi/sqlalchemy/tests/clients/test_postgres.py
+++ b/stac_fastapi/sqlalchemy/tests/clients/test_postgres.py
@@ -1,12 +1,11 @@
 import uuid
+from copy import deepcopy
 from typing import Callable
 
 import pytest
-from geojson_pydantic import Polygon
 from stac_pydantic import Collection, Item
 from tests.conftest import MockStarletteRequest
 
-from stac_fastapi.extensions.third_party.bulk_transactions import Items
 from stac_fastapi.sqlalchemy.core import CoreCrudClient
 from stac_fastapi.sqlalchemy.transactions import (
     BulkTransactionsClient,
@@ -20,19 +19,20 @@ def test_create_collection(
     postgres_transactions: TransactionsClient,
     load_test_data: Callable,
 ):
-    data = Collection.parse_obj(load_test_data("test_collection.json"))
+    data = load_test_data("test_collection.json")
     resp = postgres_transactions.create_collection(data, request=MockStarletteRequest)
-    assert data.dict(exclude={"links"}) == resp.dict(exclude={"links"})
-    coll = postgres_core.get_collection(data.id, request=MockStarletteRequest)
-    assert coll.id == data.id
+    assert Collection(**data).dict(exclude={"links"}) == Collection(**resp).dict(
+        exclude={"links"}
+    )
+    coll = postgres_core.get_collection(data["id"], request=MockStarletteRequest)
+    assert coll["id"] == data["id"]
 
 
 def test_create_collection_already_exists(
-    postgres_core: CoreCrudClient,
     postgres_transactions: TransactionsClient,
     load_test_data: Callable,
 ):
-    data = Collection.parse_obj(load_test_data("test_collection.json"))
+    data = load_test_data("test_collection.json")
     postgres_transactions.create_collection(data, request=MockStarletteRequest)
 
     with pytest.raises(ConflictError):
@@ -44,14 +44,14 @@ def test_update_collection(
     postgres_transactions: TransactionsClient,
     load_test_data: Callable,
 ):
-    data = Collection.parse_obj(load_test_data("test_collection.json"))
+    data = load_test_data("test_collection.json")
     postgres_transactions.create_collection(data, request=MockStarletteRequest)
 
-    data.keywords.append("new keyword")
+    data["keywords"].append("new keyword")
     postgres_transactions.update_collection(data, request=MockStarletteRequest)
 
-    coll = postgres_core.get_collection(data.id, request=MockStarletteRequest)
-    assert "new keyword" in coll.keywords
+    coll = postgres_core.get_collection(data["id"], request=MockStarletteRequest)
+    assert "new keyword" in coll["keywords"]
 
 
 def test_delete_collection(
@@ -59,15 +59,15 @@ def test_delete_collection(
     postgres_transactions: TransactionsClient,
     load_test_data: Callable,
 ):
-    data = Collection.parse_obj(load_test_data("test_collection.json"))
+    data = load_test_data("test_collection.json")
     postgres_transactions.create_collection(data, request=MockStarletteRequest)
 
     deleted = postgres_transactions.delete_collection(
-        data.id, request=MockStarletteRequest
+        data["id"], request=MockStarletteRequest
     )
 
     with pytest.raises(NotFoundError):
-        postgres_core.get_collection(deleted.id, request=MockStarletteRequest)
+        postgres_core.get_collection(deleted["id"], request=MockStarletteRequest)
 
 
 def test_get_collection(
@@ -75,11 +75,13 @@ def test_get_collection(
     postgres_transactions: TransactionsClient,
     load_test_data: Callable,
 ):
-    data = Collection.parse_obj(load_test_data("test_collection.json"))
+    data = load_test_data("test_collection.json")
     postgres_transactions.create_collection(data, request=MockStarletteRequest)
-    coll = postgres_core.get_collection(data.id, request=MockStarletteRequest)
-    assert data.dict(exclude={"links"}) == coll.dict(exclude={"links"})
-    assert coll.id == data.id
+    coll = postgres_core.get_collection(data["id"], request=MockStarletteRequest)
+    assert Collection(**data).dict(exclude={"links"}) == Collection(**coll).dict(
+        exclude={"links"}
+    )
+    assert coll["id"] == data["id"]
 
 
 def test_get_collection_items(
@@ -87,20 +89,20 @@ def test_get_collection_items(
     postgres_transactions: TransactionsClient,
     load_test_data: Callable,
 ):
-    coll = Collection.parse_obj(load_test_data("test_collection.json"))
+    coll = load_test_data("test_collection.json")
     postgres_transactions.create_collection(coll, request=MockStarletteRequest)
 
-    item = Item.parse_obj(load_test_data("test_item.json"))
+    item = load_test_data("test_item.json")
 
     for _ in range(5):
-        item.id = str(uuid.uuid4())
+        item["id"] = str(uuid.uuid4())
         postgres_transactions.create_item(item, request=MockStarletteRequest)
 
-    fc = postgres_core.item_collection(coll.id, request=MockStarletteRequest)
-    assert len(fc.features) == 5
+    fc = postgres_core.item_collection(coll["id"], request=MockStarletteRequest)
+    assert len(fc["features"]) == 5
 
-    for item in fc.features:
-        assert item.collection == coll.id
+    for item in fc["features"]:
+        assert item["collection"] == coll["id"]
 
 
 def test_create_item(
@@ -108,27 +110,26 @@ def test_create_item(
     postgres_transactions: TransactionsClient,
     load_test_data: Callable,
 ):
-    coll = Collection.parse_obj(load_test_data("test_collection.json"))
+    coll = load_test_data("test_collection.json")
     postgres_transactions.create_collection(coll, request=MockStarletteRequest)
-    item = Item.parse_obj(load_test_data("test_item.json"))
+    item = load_test_data("test_item.json")
     postgres_transactions.create_item(item, request=MockStarletteRequest)
     resp = postgres_core.get_item(
-        item.id, item.collection, request=MockStarletteRequest
+        item["id"], item["collection"], request=MockStarletteRequest
     )
-    assert item.dict(
+    assert Item(**item).dict(
         exclude={"links": ..., "properties": {"created", "updated"}}
-    ) == resp.dict(exclude={"links": ..., "properties": {"created", "updated"}})
+    ) == Item(**resp).dict(exclude={"links": ..., "properties": {"created", "updated"}})
 
 
 def test_create_item_already_exists(
-    postgres_core: CoreCrudClient,
     postgres_transactions: TransactionsClient,
     load_test_data: Callable,
 ):
-    coll = Collection.parse_obj(load_test_data("test_collection.json"))
+    coll = load_test_data("test_collection.json")
     postgres_transactions.create_collection(coll, request=MockStarletteRequest)
 
-    item = Item.parse_obj(load_test_data("test_item.json"))
+    item = load_test_data("test_item.json")
     postgres_transactions.create_item(item, request=MockStarletteRequest)
 
     with pytest.raises(ConflictError):
@@ -140,19 +141,19 @@ def test_update_item(
     postgres_transactions: TransactionsClient,
     load_test_data: Callable,
 ):
-    coll = Collection.parse_obj(load_test_data("test_collection.json"))
+    coll = load_test_data("test_collection.json")
     postgres_transactions.create_collection(coll, request=MockStarletteRequest)
 
-    item = Item.parse_obj(load_test_data("test_item.json"))
+    item = load_test_data("test_item.json")
     postgres_transactions.create_item(item, request=MockStarletteRequest)
 
-    item.properties.foo = "bar"
+    item["properties"]["foo"] = "bar"
     postgres_transactions.update_item(item, request=MockStarletteRequest)
 
     updated_item = postgres_core.get_item(
-        item.id, item.collection, request=MockStarletteRequest
+        item["id"], item["collection"], request=MockStarletteRequest
     )
-    assert updated_item.properties.foo == "bar"
+    assert updated_item["properties"]["foo"] == "bar"
 
 
 def test_update_geometry(
@@ -160,19 +161,19 @@ def test_update_geometry(
     postgres_transactions: TransactionsClient,
     load_test_data: Callable,
 ):
-    coll = Collection.parse_obj(load_test_data("test_collection.json"))
+    coll = load_test_data("test_collection.json")
     postgres_transactions.create_collection(coll, request=MockStarletteRequest)
 
-    item = Item.parse_obj(load_test_data("test_item.json"))
+    item = load_test_data("test_item.json")
     postgres_transactions.create_item(item, request=MockStarletteRequest)
 
-    item.geometry = Polygon(coordinates=[[[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]]])
+    item["geometry"]["coordinates"] = [[[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]]]
     postgres_transactions.update_item(item, request=MockStarletteRequest)
 
     updated_item = postgres_core.get_item(
-        item.id, item.collection, request=MockStarletteRequest
+        item["id"], item["collection"], request=MockStarletteRequest
     )
-    assert updated_item.geometry == item.geometry
+    assert updated_item["geometry"]["coordinates"] == item["geometry"]["coordinates"]
 
 
 def test_delete_item(
@@ -180,18 +181,20 @@ def test_delete_item(
     postgres_transactions: TransactionsClient,
     load_test_data: Callable,
 ):
-    coll = Collection.parse_obj(load_test_data("test_collection.json"))
+    coll = load_test_data("test_collection.json")
     postgres_transactions.create_collection(coll, request=MockStarletteRequest)
 
-    item = Item.parse_obj(load_test_data("test_item.json"))
+    item = load_test_data("test_item.json")
     postgres_transactions.create_item(item, request=MockStarletteRequest)
 
     postgres_transactions.delete_item(
-        item.id, item.collection, request=MockStarletteRequest
+        item["id"], item["collection"], request=MockStarletteRequest
     )
 
     with pytest.raises(NotFoundError):
-        postgres_core.get_item(item.id, item.collection, request=MockStarletteRequest)
+        postgres_core.get_item(
+            item["id"], item["collection"], request=MockStarletteRequest
+        )
 
 
 def test_bulk_item_insert(
@@ -199,22 +202,22 @@ def test_bulk_item_insert(
     postgres_bulk_transactions: BulkTransactionsClient,
     load_test_data: Callable,
 ):
-    coll = Collection.parse_obj(load_test_data("test_collection.json"))
+    coll = load_test_data("test_collection.json")
     postgres_transactions.create_collection(coll, request=MockStarletteRequest)
 
-    item = Item.parse_obj(load_test_data("test_item.json"))
+    item = load_test_data("test_item.json")
 
     items = []
     for _ in range(10):
-        _item = item.dict()
+        _item = deepcopy(item)
         _item["id"] = str(uuid.uuid4())
         items.append(_item)
 
-    postgres_bulk_transactions.bulk_item_insert(Items(items=items))
+    postgres_bulk_transactions.bulk_item_insert(items=items)
 
     for item in items:
         postgres_transactions.delete_item(
-            item["id"], item["collection"], request=MockStarletteRequest
+            item["id"], item["collection_id"], request=MockStarletteRequest
         )
 
 
@@ -223,20 +226,20 @@ def test_bulk_item_insert_chunked(
     postgres_bulk_transactions: BulkTransactionsClient,
     load_test_data: Callable,
 ):
-    coll = Collection.parse_obj(load_test_data("test_collection.json"))
+    coll = load_test_data("test_collection.json")
     postgres_transactions.create_collection(coll, request=MockStarletteRequest)
 
-    item = Item.parse_obj(load_test_data("test_item.json"))
+    item = load_test_data("test_item.json")
 
     items = []
     for _ in range(10):
-        _item = item.dict()
+        _item = deepcopy(item)
         _item["id"] = str(uuid.uuid4())
         items.append(_item)
 
-    postgres_bulk_transactions.bulk_item_insert(Items(items=items), chunk_size=2)
+    postgres_bulk_transactions.bulk_item_insert(items=items, chunk_size=2)
 
     for item in items:
         postgres_transactions.delete_item(
-            item["id"], item["collection"], request=MockStarletteRequest
+            item["id"], item["collection_id"], request=MockStarletteRequest
         )
diff --git a/stac_fastapi/sqlalchemy/tests/conftest.py b/stac_fastapi/sqlalchemy/tests/conftest.py
index 33afe8615..acf219ca5 100644
--- a/stac_fastapi/sqlalchemy/tests/conftest.py
+++ b/stac_fastapi/sqlalchemy/tests/conftest.py
@@ -3,7 +3,6 @@
 from typing import Callable, Dict
 
 import pytest
-from stac_pydantic import Collection
 from starlette.testclient import TestClient
 
 from stac_fastapi.api.app import StacApi
@@ -42,19 +41,19 @@ def cleanup(postgres_core: CoreCrudClient, postgres_transactions: TransactionsCl
     yield
     collections = postgres_core.all_collections(request=MockStarletteRequest)
     for coll in collections:
-        if coll.id.split("-")[0] == "test":
+        if coll["id"].split("-")[0] == "test":
             # Delete the items
             items = postgres_core.item_collection(
-                coll.id, limit=100, request=MockStarletteRequest
+                coll["id"], limit=100, request=MockStarletteRequest
             )
-            for feat in items.features:
+            for feat in items["features"]:
                 postgres_transactions.delete_item(
-                    feat.id, feat.collection, request=MockStarletteRequest
+                    feat["id"], feat["collection"], request=MockStarletteRequest
                 )
 
             # Delete the collection
             postgres_transactions.delete_collection(
-                coll.id, request=MockStarletteRequest
+                coll["id"], request=MockStarletteRequest
             )
 
 
@@ -105,11 +104,14 @@ def postgres_bulk_transactions(db_session):
 
 @pytest.fixture
 def api_client(db_session):
+    settings = SqlalchemySettings()
     return StacApi(
-        settings=SqlalchemySettings(),
+        settings=settings,
         client=CoreCrudClient(session=db_session),
         extensions=[
-            TransactionExtension(client=TransactionsClient(session=db_session)),
+            TransactionExtension(
+                client=TransactionsClient(session=db_session), settings=settings
+            ),
             ContextExtension(),
             SortExtension(),
             FieldsExtension(),
@@ -121,7 +123,7 @@ def api_client(db_session):
 
 @pytest.fixture
 def app_client(api_client, load_test_data, postgres_transactions):
-    coll = Collection.parse_obj(load_test_data("test_collection.json"))
+    coll = load_test_data("test_collection.json")
     postgres_transactions.create_collection(coll, request=MockStarletteRequest)
 
     with TestClient(api_client.app) as test_app:
diff --git a/stac_fastapi/sqlalchemy/tests/data/test_collection.json b/stac_fastapi/sqlalchemy/tests/data/test_collection.json
index 04d309cb6..5028bfeae 100644
--- a/stac_fastapi/sqlalchemy/tests/data/test_collection.json
+++ b/stac_fastapi/sqlalchemy/tests/data/test_collection.json
@@ -1,7 +1,7 @@
 {
   "id": "test-collection",
-  "stac_extensions": [],
-  "type": "collection",
+  "stac_extensions": ["https://stac-extensions.github.io/eo/v1.0.0/schema.json"],
+  "type": "Collection",
   "description": "Landat 8 imagery radiometrically calibrated and orthorectified using gound points and Digital Elevation Model (DEM) data to correct relief displacement.",
   "stac_version": "1.0.0",
   "license": "PDDL-1.0",
diff --git a/stac_fastapi/sqlalchemy/tests/data/test_item.json b/stac_fastapi/sqlalchemy/tests/data/test_item.json
index 02edd0ec2..2b7fdd860 100644
--- a/stac_fastapi/sqlalchemy/tests/data/test_item.json
+++ b/stac_fastapi/sqlalchemy/tests/data/test_item.json
@@ -1,6 +1,7 @@
 {
   "type": "Feature",
   "id": "test-item",
+  "stac_version": "1.0.0",
   "stac_extensions": [
     "https://stac-extensions.github.io/eo/v1.0.0/schema.json",
     "https://stac-extensions.github.io/projection/v1.0.0/schema.json"
@@ -120,7 +121,7 @@
     "view:sun_azimuth": -148.83296771,
     "instrument": "OLI_TIRS",
     "landsat:product_id": "LC08_L1GT_208161_20200212_20200212_01_RT",
-    "eo:cloud_cover": -1,
+    "eo:cloud_cover": 0,
     "landsat:tier": "RT",
     "landsat:processing_level": "L1GT",
     "landsat:column": "208",
@@ -139,101 +140,344 @@
   ],
   "collection": "test-collection",
   "assets": {
-    "B1": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 1 (coastal)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B1.TIF",
-      "description": "this is a description"
-    },
-    "B2": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 2 (blue)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B2.TIF",
-      "description": "this is a description"
+    "ANG": {
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ANG.txt",
+      "type": "text/plain",
+      "title": "Angle Coefficients File",
+      "description": "Collection 2 Level-1 Angle Coefficients File (ANG)"
     },
-    "B3": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 3 (green)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B3.TIF",
-      "description": "this is a description"
+    "SR_B1": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B1.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Coastal/Aerosol Band (B1)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B1",
+          "common_name": "coastal",
+          "center_wavelength": 0.44,
+          "full_width_half_max": 0.02
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Coastal/Aerosol Band (B1) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B4": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 4 (red)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B4.TIF",
-      "description": "this is a description"
+    "SR_B2": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B2.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Blue Band (B2)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B2",
+          "common_name": "blue",
+          "center_wavelength": 0.48,
+          "full_width_half_max": 0.06
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Blue Band (B2) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B5": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 5 (nir)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B5.TIF",
-      "description": "this is a description"
+    "SR_B3": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B3.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Green Band (B3)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B3",
+          "common_name": "green",
+          "center_wavelength": 0.56,
+          "full_width_half_max": 0.06
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Green Band (B3) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B6": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 6 (swir16)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B6.TIF",
-      "description": "this is a description"
+    "SR_B4": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B4.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Red Band (B4)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B4",
+          "common_name": "red",
+          "center_wavelength": 0.65,
+          "full_width_half_max": 0.04
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Red Band (B4) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B7": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 7 (swir22)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B7.TIF",
-      "description": "this is a description"
+    "SR_B5": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B5.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Near Infrared Band 0.8 (B5)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B5",
+          "common_name": "nir08",
+          "center_wavelength": 0.86,
+          "full_width_half_max": 0.03
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Near Infrared Band 0.8 (B5) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B8": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 8 (pan)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B8.TIF",
-      "description": "this is a description"
+    "SR_B6": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B6.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Short-wave Infrared Band 1.6 (B6)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B6",
+          "common_name": "swir16",
+          "center_wavelength": 1.6,
+          "full_width_half_max": 0.08
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Short-wave Infrared Band 1.6 (B6) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B9": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 9 (cirrus)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B9.TIF",
-      "description": "this is a description"
+    "SR_B7": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B7.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Short-wave Infrared Band 2.2 (B7)",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "SR_B7",
+          "common_name": "swir22",
+          "center_wavelength": 2.2,
+          "full_width_half_max": 0.2
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Collection 2 Level-2 Short-wave Infrared Band 2.2 (B7) Surface Reflectance",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "ANG": {
-      "type": "text/plain",
-      "title": "Angle coefficients file",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_ANG.txt",
-      "description": "this is a description"
+    "ST_QA": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_QA.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Surface Temperature Quality Assessment Band",
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Landsat Collection 2 Level-2 Surface Temperature Band Surface Temperature Product",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B10": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 10 (lwir)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B10.TIF",
-      "description": "this is a description"
+    "ST_B10": {
+      "gsd": 100,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_B10.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Surface Temperature Band (B10)",
+      "eo:bands": [
+        {
+          "gsd": 100,
+          "name": "ST_B10",
+          "common_name": "lwir11",
+          "center_wavelength": 10.9,
+          "full_width_half_max": 0.8
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Landsat Collection 2 Level-2 Surface Temperature Band (B10) Surface Temperature Product",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "B11": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band 11 (lwir)",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_B11.TIF",
-      "description": "this is a description"
+    "MTL.txt": {
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_MTL.txt",
+      "type": "text/plain",
+      "title": "Product Metadata File",
+      "description": "Collection 2 Level-1 Product Metadata File (MTL)"
     },
-    "BQA": {
-      "type": "image/tiff; application=geotiff",
-      "title": "Band quality data",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_BQA.TIF",
-      "description": "this is a description"
+    "MTL.xml": {
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_MTL.xml",
+      "type": "application/xml",
+      "title": "Product Metadata File (xml)",
+      "description": "Collection 2 Level-1 Product Metadata File (xml)"
     },
-    "MTL": {
-      "type": "text/plain",
-      "title": "original metadata file",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_MTL.txt",
-      "description": "this is a description"
+    "ST_DRAD": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_DRAD.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Downwelled Radiance Band",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "ST_DRAD",
+          "description": "downwelled radiance"
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Landsat Collection 2 Level-2 Downwelled Radiance Band Surface Temperature Product",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "index": {
-      "type": "text/html",
-      "title": "HTML index page",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_MTL.txt",
-      "description": "this is a description"
+    "ST_EMIS": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_EMIS.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Emissivity Band",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "ST_EMIS",
+          "description": "emissivity"
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Landsat Collection 2 Level-2 Emissivity Band Surface Temperature Product",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     },
-    "thumbnail": {
-      "type": "image/jpeg",
-      "title": "Thumbnail image",
-      "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/208/161/LC08_L1GT_208161_20200212_20200212_01_RT/LC08_L1GT_208161_20200212_20200212_01_RT_thumb_large.jpg",
-      "description": "this is a description"
+    "ST_EMSD": {
+      "gsd": 30,
+      "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_EMSD.TIF",
+      "type": "image/tiff; application=geotiff; profile=cloud-optimized",
+      "title": "Emissivity Standard Deviation Band",
+      "eo:bands": [
+        {
+          "gsd": 30,
+          "name": "ST_EMSD",
+          "description": "emissivity standard deviation"
+        }
+      ],
+      "proj:shape": [
+        7731,
+        7591
+      ],
+      "description": "Landsat Collection 2 Level-2 Emissivity Standard Deviation Band Surface Temperature Product",
+      "proj:transform": [
+        30,
+        0,
+        304185,
+        0,
+        -30,
+        -843585
+      ]
     }
   },
   "links": [
diff --git a/stac_fastapi/sqlalchemy/tests/features/test_tiles_extension.py b/stac_fastapi/sqlalchemy/tests/features/test_tiles_extension.py
index 3533745b4..8a1de9136 100644
--- a/stac_fastapi/sqlalchemy/tests/features/test_tiles_extension.py
+++ b/stac_fastapi/sqlalchemy/tests/features/test_tiles_extension.py
@@ -1,7 +1,6 @@
 from urllib.parse import urlsplit
 
 import pytest
-from stac_pydantic import Collection, Item
 from starlette.testclient import TestClient
 
 from stac_fastapi.api.app import StacApi
@@ -14,10 +13,10 @@
 @pytest.fixture
 def tiles_extension_app(postgres_core, postgres_transactions, load_test_data):
     # Ingest test data for testing
-    coll = Collection.parse_obj(load_test_data("test_collection.json"))
+    coll = load_test_data("test_collection.json")
     postgres_transactions.create_collection(coll, request=MockStarletteRequest)
 
-    item = Item.parse_obj(load_test_data("test_item.json"))
+    item = load_test_data("test_item.json")
     postgres_transactions.create_item(item, request=MockStarletteRequest)
 
     settings = SqlalchemySettings()
@@ -31,9 +30,9 @@ def tiles_extension_app(postgres_core, postgres_transactions, load_test_data):
 
     # Cleanup test data
     postgres_transactions.delete_item(
-        item.id, item.collection, request=MockStarletteRequest
+        item["id"], item["collection"], request=MockStarletteRequest
     )
-    postgres_transactions.delete_collection(coll.id, request=MockStarletteRequest)
+    postgres_transactions.delete_collection(coll["id"], request=MockStarletteRequest)
 
 
 def test_tiles_extension(tiles_extension_app, load_test_data):
diff --git a/stac_fastapi/sqlalchemy/tests/resources/test_collection.py b/stac_fastapi/sqlalchemy/tests/resources/test_collection.py
index 1a1a3b3c1..b0d8b3d66 100644
--- a/stac_fastapi/sqlalchemy/tests/resources/test_collection.py
+++ b/stac_fastapi/sqlalchemy/tests/resources/test_collection.py
@@ -1,3 +1,6 @@
+import pystac
+
+
 def test_create_and_delete_collection(app_client, load_test_data):
     """Test creation and deletion of a collection"""
     test_collection = load_test_data("test_collection.json")
@@ -50,3 +53,23 @@ def test_collection_not_found(app_client):
     """Test read a collection which does not exist"""
     resp = app_client.get("/collections/does-not-exist")
     assert resp.status_code == 404
+
+
+def test_returns_valid_collection(app_client, load_test_data):
+    """Test validates fetched collection with jsonschema"""
+    test_collection = load_test_data("test_collection.json")
+    resp = app_client.put("/collections", json=test_collection)
+    assert resp.status_code == 200
+
+    resp = app_client.get(f"/collections/{test_collection['id']}")
+    assert resp.status_code == 200
+    resp_json = resp.json()
+
+    # Mock root to allow validation
+    mock_root = pystac.Catalog(
+        id="test", description="test desc", href="https://example.com"
+    )
+    collection = pystac.Collection.from_dict(
+        resp_json, root=mock_root, preserve_dict=False
+    )
+    collection.validate()
diff --git a/stac_fastapi/sqlalchemy/tests/resources/test_item.py b/stac_fastapi/sqlalchemy/tests/resources/test_item.py
index 86f48dd2b..c38a1618c 100644
--- a/stac_fastapi/sqlalchemy/tests/resources/test_item.py
+++ b/stac_fastapi/sqlalchemy/tests/resources/test_item.py
@@ -6,6 +6,7 @@
 from random import randint
 from urllib.parse import parse_qs, urlparse, urlsplit
 
+import pystac
 from pydantic.datetime_parse import parse_datetime
 from shapely.geometry import Polygon
 from stac_pydantic.shared import DATETIME_RFC339
@@ -140,6 +141,27 @@ def test_get_item(app_client, load_test_data):
     assert get_item.status_code == 200
 
 
+def test_returns_valid_item(app_client, load_test_data):
+    """Test validates fetched item with jsonschema"""
+    test_item = load_test_data("test_item.json")
+    resp = app_client.post(
+        f"/collections/{test_item['collection']}/items", json=test_item
+    )
+    assert resp.status_code == 200
+
+    get_item = app_client.get(
+        f"/collections/{test_item['collection']}/items/{test_item['id']}"
+    )
+    assert get_item.status_code == 200
+    item_dict = get_item.json()
+    # Mock root to allow validation
+    mock_root = pystac.Catalog(
+        id="test", description="test desc", href="https://example.com"
+    )
+    item = pystac.Item.from_dict(item_dict, preserve_dict=False, root=mock_root)
+    item.validate()
+
+
 def test_get_item_collection(app_client, load_test_data):
     """Test read an item collection (core)"""
     item_count = randint(1, 4)