diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,18 @@
+# Changelog for relay-pagination-servant
+
+Versioning follows the [Haskell Package Versioning Policy](https://pvp.haskell.org/).
+
+## 0.1.0.0 — 2026-07-16
+
+Initial contents:
+
+- The `RelayPage defSize maxSize` combinator: declares the four Relay query
+  parameters on a route and hands the handler one validated `PageRequest`,
+  answering HTTP 400 with a stable-coded JSON `RelayPageError` body before
+  the handler runs.
+- `HasServer`, `HasClient` (via the `ClientPage` argument record with
+  `noPageArgs`/`forwardPage`/`backwardPage`), and `HasLink` instances.
+- OpenAPI 3.1 documentation (`Relay.Pagination.Servant.OpenApi`, via
+  `openapi-hs`/`servant-openapi-hs`): the four parameters with bounds and
+  defaults, plus the canonical `ToSchema`/`ToParamSchema` orphans for the
+  core pagination types.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2025, Nadeem Bitar
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of the copyright holder nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/demo/Main.hs b/demo/Main.hs
new file mode 100644
--- /dev/null
+++ b/demo/Main.hs
@@ -0,0 +1,14 @@
+-- | Toy demo server for the curl transcript in EP-2's Validation section.
+-- Serves the same shared 'ToyApi.toyApp' the tests and the OpenAPI generator
+-- use, on port 8080:
+--
+-- > cabal run relay-pagination-servant:relay-demo
+module Main (main) where
+
+import Network.Wai.Handler.Warp (run)
+import ToyApi (toyApp)
+
+main :: IO ()
+main = do
+  putStrLn "relay-demo: serving the toy paginated API on http://localhost:8080/items"
+  run 8080 toyApp
diff --git a/demo/OpenApiMain.hs b/demo/OpenApiMain.hs
new file mode 100644
--- /dev/null
+++ b/demo/OpenApiMain.hs
@@ -0,0 +1,18 @@
+-- | Writes the checked OpenAPI 3.1 artifact. This executable is the __only__
+-- thing allowed to write @relay-pagination-servant/test/golden/toy-openapi.json@;
+-- the test suite compares the checked bytes read-only (no @--accept@ mode).
+--
+-- Run from the repository root:
+--
+-- > cabal run relay-pagination-servant:relay-demo-openapi
+module Main (main) where
+
+import Data.ByteString.Lazy qualified as LBS
+import ToyOpenApi (renderToyOpenApi)
+
+main :: IO ()
+main = do
+  LBS.writeFile artifactPath renderToyOpenApi
+  putStrLn ("wrote " <> artifactPath)
+  where
+    artifactPath = "relay-pagination-servant/test/golden/toy-openapi.json"
diff --git a/demo/ToyApi.hs b/demo/ToyApi.hs
new file mode 100644
--- /dev/null
+++ b/demo/ToyApi.hs
@@ -0,0 +1,111 @@
+-- | The one shared toy API used by the test suite, the @relay-demo@ server,
+-- and the @relay-demo-openapi@ generator. There is exactly one 'toyApi'
+-- proxy; serving, client generation, links, and OpenAPI derivation all use
+-- it, so what is tested, served, and documented cannot drift apart.
+--
+-- The handler echoes the validated 'PageRequest' back into the response —
+-- the page size becomes 'itemId' and the direction becomes 'itemName' — so
+-- tests can observe exactly what the 'RelayPage' combinator produced.
+module ToyApi
+  ( Item (..),
+    ToyPageResponses,
+    ToyPageResult (..),
+    ToyEndpoint,
+    ToyItemsEndpoint,
+    ToyRoutes (..),
+    toyApi,
+    toyServer,
+    toyApp,
+    toyCursor,
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON)
+import Data.OpenApi (ToSchema)
+import Data.Proxy (Proxy (..))
+import Data.SOP (I (..), NS (..))
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Network.Wai (Application)
+import Relay.Pagination
+import Relay.Pagination.Servant
+import Servant (Handler, serve)
+import Servant.API (JSON, NamedRoutes, StdMethod (GET), (:-), (:>))
+import Servant.API.MultiVerb (AsUnion (..), MultiVerb, Respond)
+import Servant.Server.Generic (AsServerT)
+
+data Item = Item
+  { itemId :: !Int,
+    itemName :: !Text
+  }
+  deriving stock (Eq, Show, Generic)
+  -- ToSchema lives here, next to the definition, where it is not an orphan.
+  deriving anyclass (ToJSON, FromJSON, ToSchema)
+
+type ToyPageResponses =
+  '[ Respond 200 "Page of items" (Connection Item),
+     Respond 400 "Invalid pagination" RelayPageError
+   ]
+
+data ToyPageResult
+  = ToyPageOk !(Connection Item)
+  | ToyPageBadRequest !RelayPageError
+  deriving stock (Eq, Show)
+
+-- | Hand-written on purpose: the mapping between a result constructor and
+-- its HTTP status is load-bearing, so adding or reordering a response
+-- alternative must stop compiling rather than silently renumber.
+instance AsUnion ToyPageResponses ToyPageResult where
+  toUnion = \case
+    ToyPageOk value -> Z (I value)
+    ToyPageBadRequest err -> S (Z (I err))
+  fromUnion = \case
+    Z (I value) -> ToyPageOk value
+    S (Z (I err)) -> ToyPageBadRequest err
+    S (S impossible) -> case impossible of {}
+
+type ToyEndpoint =
+  RelayPage 10 100
+    :> MultiVerb 'GET '[JSON] ToyPageResponses ToyPageResult
+
+type ToyItemsEndpoint = "items" :> ToyEndpoint
+
+data ToyRoutes mode = ToyRoutes
+  { items :: mode :- ToyItemsEndpoint
+  }
+  deriving stock (Generic)
+
+-- | The single proxy shared by @serve@, @genericClient@, @safeLink@, and
+-- @toOpenApi@.
+toyApi :: Proxy (NamedRoutes ToyRoutes)
+toyApi = Proxy
+
+-- | A fixed, well-formed cursor for the toy edges: base64url of @edge-1@.
+toyCursor :: Cursor
+toyCursor = Cursor "ZWRnZS0x"
+
+toyServer :: ToyRoutes (AsServerT Handler)
+toyServer = ToyRoutes {items = serveItems}
+  where
+    serveItems pr =
+      pure . ToyPageOk $
+        Connection
+          { edges =
+              [ Edge
+                  { node = Item {itemId = pageSize pr, itemName = directionText (direction pr)},
+                    cursor = toyCursor
+                  }
+              ],
+            pageInfo =
+              PageInfo
+                { hasNextPage = False,
+                  hasPreviousPage = False,
+                  startCursor = Just toyCursor,
+                  endCursor = Just toyCursor
+                }
+          }
+    directionText Forward = "forward" :: Text
+    directionText Backward = "backward"
+
+toyApp :: Application
+toyApp = serve toyApi toyServer
diff --git a/demo/ToyOpenApi.hs b/demo/ToyOpenApi.hs
new file mode 100644
--- /dev/null
+++ b/demo/ToyOpenApi.hs
@@ -0,0 +1,27 @@
+-- | Deterministic rendering of the toy API's OpenAPI 3.1 document, shared by
+-- the @relay-demo-openapi@ generator (which writes the checked artifact) and
+-- the test suite (which compares the checked bytes against this exact
+-- rendering, read-only).
+module ToyOpenApi
+  ( toyOpenApi,
+    renderToyOpenApi,
+  )
+where
+
+import Control.Lens ((&), (?~))
+import Data.Aeson.Encode.Pretty (Config (..), defConfig, encodePretty')
+import Data.ByteString.Lazy (ByteString)
+import Data.OpenApi (OpenApi, allOperations, operationId)
+import Relay.Pagination.Servant.OpenApi ()
+import Servant.OpenApi (toOpenApi)
+import ToyApi (toyApi)
+
+-- | Derived from the exact proxy passed to @serve@, then enriched with the
+-- one thing the route types cannot carry: a stable operation id.
+toyOpenApi :: OpenApi
+toyOpenApi = toOpenApi toyApi & allOperations . operationId ?~ "getItems"
+
+-- | Byte-stable output for the checked artifact and the drift test: sorted
+-- keys, one trailing newline.
+renderToyOpenApi :: ByteString
+renderToyOpenApi = encodePretty' defConfig {confCompare = compare} toyOpenApi <> "\n"
diff --git a/relay-pagination-servant.cabal b/relay-pagination-servant.cabal
new file mode 100644
--- /dev/null
+++ b/relay-pagination-servant.cabal
@@ -0,0 +1,141 @@
+cabal-version:   3.0
+name:            relay-pagination-servant
+version:         0.1.0.0
+synopsis:
+  RelayPage servant combinator for Relay-style cursor pagination
+
+description:
+  The RelayPage servant combinator: declares the four Relay pagination query
+  parameters (first, after, last, before) on a route, validates them before
+  the handler runs, and hands the handler one already-validated PageRequest.
+  Ships HasServer, HasClient, and HasLink instances plus OpenAPI 3.1
+  documentation support via openapi-hs and servant-openapi-hs.
+
+license:         BSD-3-Clause
+license-file:    LICENSE
+author:          Nadeem Bitar
+maintainer:      Nadeem Bitar
+category:        Web, Servant
+build-type:      Simple
+extra-doc-files: CHANGELOG.md
+
+source-repository head
+  type:     git
+  location: https://github.com/shinzui/relay-pagination
+
+common warnings
+  ghc-options:
+    -Wall -Wcompat -Widentities -Wincomplete-record-updates
+    -Wincomplete-uni-patterns -Wredundant-constraints -Wunused-packages
+
+common lang
+  import:             warnings
+  default-language:   GHC2024
+  default-extensions:
+    DeriveAnyClass
+    DuplicateRecordFields
+    OverloadedLabels
+    OverloadedStrings
+
+library
+  import:          lang
+  hs-source-dirs:  src
+  exposed-modules:
+    Relay.Pagination.Servant
+    Relay.Pagination.Servant.OpenApi
+
+  build-depends:
+    , aeson                >=2.2      && <2.3
+    , base                 >=4.21     && <5
+    , http-api-data        >=0.6      && <0.7
+    , http-types           >=0.12     && <0.13
+    , lens                 >=5.2      && <5.4
+    , openapi-hs           >=4.1      && <4.2
+    , relay-pagination     ^>=0.1.0.0
+    , servant              >=0.20.3   && <0.21
+    , servant-client-core  >=0.20.3   && <0.21
+    , servant-openapi-hs   >=4.1      && <4.2
+    , servant-server       >=0.20.3   && <0.21
+    , text                 >=2.0      && <2.2
+    , wai                  >=3.2      && <3.3
+
+test-suite relay-pagination-servant-test
+  import:         lang
+  type:           exitcode-stdio-1.0
+  hs-source-dirs: test demo
+  main-is:        Main.hs
+  other-modules:
+    ToyApi
+    ToyOpenApi
+
+  -- warp requires the threaded RTS (its TimerManager will not start without it)
+  ghc-options:    -threaded
+  build-depends:
+    , aeson
+    , aeson-pretty               >=0.8      && <0.9
+    , base
+    , bytestring                 >=0.11     && <0.13
+    , http-api-data
+    , http-client                >=0.7      && <0.8
+    , http-types
+    , insert-ordered-containers  >=0.2      && <0.3
+    , lens
+    , openapi-hs
+    , relay-pagination           ^>=0.1.0.0
+    , relay-pagination-servant
+    , servant
+    , servant-client             >=0.20.3   && <0.21
+    , servant-client-core
+    , servant-openapi-hs
+    , servant-server
+    , sop-core                   >=0.5      && <0.6
+    , tasty                      >=1.4      && <1.6
+    , tasty-hunit                >=0.10     && <0.11
+    , text
+    , wai
+    , warp                       >=3.3      && <3.5
+
+executable relay-demo
+  import:         lang
+  hs-source-dirs: demo
+  main-is:        Main.hs
+  other-modules:  ToyApi
+
+  -- warp requires the threaded RTS (its TimerManager will not start without it)
+  ghc-options:    -threaded
+  build-depends:
+    , aeson
+    , base
+    , openapi-hs
+    , relay-pagination          ^>=0.1.0.0
+    , relay-pagination-servant
+    , servant
+    , servant-server
+    , sop-core                  >=0.5      && <0.6
+    , text
+    , wai
+    , warp                      >=3.3      && <3.5
+
+executable relay-demo-openapi
+  import:         lang
+  hs-source-dirs: demo
+  main-is:        OpenApiMain.hs
+  other-modules:
+    ToyApi
+    ToyOpenApi
+
+  build-depends:
+    , aeson
+    , aeson-pretty              >=0.8      && <0.9
+    , base
+    , bytestring                >=0.11     && <0.13
+    , lens
+    , openapi-hs
+    , relay-pagination          ^>=0.1.0.0
+    , relay-pagination-servant
+    , servant
+    , servant-openapi-hs
+    , servant-server
+    , sop-core                  >=0.5      && <0.6
+    , text
+    , wai
diff --git a/src/Relay/Pagination/Servant.hs b/src/Relay/Pagination/Servant.hs
new file mode 100644
--- /dev/null
+++ b/src/Relay/Pagination/Servant.hs
@@ -0,0 +1,242 @@
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+-- | The 'RelayPage' servant combinator: Relay-style cursor pagination for
+-- plain REST endpoints.
+--
+-- Writing @'RelayPage' 10 100 :> ...@ in a route declares the four Relay
+-- pagination query parameters — @first@, @after@, @last@, @before@ — with a
+-- default page size of 10 and a maximum of 100. The handler receives a single
+-- already-validated 'PageRequest'; every invalid request is answered before
+-- the handler runs with HTTP 400 and a 'RelayPageError' JSON body.
+--
+-- == The 400 error-body contract
+--
+-- The status is always 400 and the body is the JSON representation of
+-- 'RelayPageError', with exactly the keys @code@, @message@, @retryable@, and
+-- @parameter@. Clients must branch on @code@, never on @message@ (which is
+-- explanatory prose and may change). @retryable@ is always @false@ for
+-- request validation. @parameter@ names one offending query parameter
+-- (@"first"@, @"after"@, @"last"@, or @"before"@) or is JSON @null@ when no
+-- single parameter is responsible. The stable codes:
+--
+-- * @invalid_integer@ — @first@ or @last@ is not a decimal integer.
+-- * @invalid_cursor@ — @after@ or @before@ is not unpadded base64url.
+-- * @mixed_pagination_directions@ — parameters from both the forward family
+--   (@first@, @after@) and the backward family (@last@, @before@) were
+--   supplied.
+-- * @negative_page_size@ — @first@ or @last@ is negative.
+-- * @page_size_too_large@ — @first@ or @last@ exceeds the route's maximum.
+--
+-- Routes should declare the same 'RelayPageError' type in a 400 'MultiVerb'
+-- alternative so generated clients and OpenAPI documents can represent the
+-- response the combinator actually produces at runtime.
+--
+-- The servant layer validates only that a cursor is well-formed unpadded
+-- base64url; payload validation (version, fingerprint, key types) belongs to
+-- the layer that knows the endpoint's sort specification (the hasql engine).
+module Relay.Pagination.Servant
+  ( RelayPage,
+    RelayPageError (..),
+    ClientPage (..),
+    noPageArgs,
+    forwardPage,
+    backwardPage,
+  )
+where
+
+import Control.Monad (join)
+import Data.Aeson qualified as Aeson
+import Data.Maybe (isJust)
+import Data.Proxy (Proxy (..))
+import Data.Text (Text)
+import Data.Text qualified as Text
+import GHC.Generics (Generic)
+import GHC.TypeLits (KnownNat, Nat, natVal)
+import Network.HTTP.Types (queryToQueryText)
+import Network.Wai (Request, queryString)
+import Relay.Pagination
+import Servant.API ((:>))
+import Servant.Client.Core qualified as Client
+import Servant.Links (HasLink (..), Link, Param (SingleParam), addQueryParam)
+import Servant.Server.Internal
+import Web.HttpApiData (ToHttpApiData, parseQueryParam, toQueryParam)
+
+-- | Declares the four Relay pagination query parameters on a route.
+--
+-- @defSize@ is the page size used when neither @first@ nor @last@ is given;
+-- @maxSize@ is the hard upper bound (requests above it get HTTP 400). Both
+-- are type-level so that the server (validation), the generated OpenAPI
+-- document, and readers of the route type all see the same numbers.
+--
+-- This type is uninhabited; only its position in the route type matters.
+data RelayPage (defSize :: Nat) (maxSize :: Nat)
+
+-- | Stable 400 response body emitted by pagination validation. See the
+-- module haddock for the full contract; in short: branch on 'code', treat
+-- 'message' as prose, expect 'retryable' to be false, and read 'parameter'
+-- as the offending query parameter when one can be named.
+data RelayPageError = RelayPageError
+  { code :: !Text,
+    message :: !Text,
+    retryable :: !Bool,
+    parameter :: !(Maybe Text)
+  }
+  deriving stock (Eq, Show, Generic)
+  deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
+
+-- | Parses and validates @first@\/@after@\/@last@\/@before@ ahead of the
+-- handler, which receives one 'PageRequest'. Validation failures abort
+-- routing fatally (a sibling route cannot match afterwards) with the 400
+-- described in the module haddock, mirroring what servant's own 'QueryParam'
+-- does on a parse failure.
+instance
+  (KnownNat defSize, KnownNat maxSize, HasServer api context) =>
+  HasServer (RelayPage defSize maxSize :> api) context
+  where
+  type ServerT (RelayPage defSize maxSize :> api) m = PageRequest -> ServerT api m
+
+  hoistServerWithContext _ pc nt s = hoistServerWithContext (Proxy @api) pc nt . s
+
+  route _ context subserver =
+    route (Proxy @api) context (addParameterCheck subserver (withRequest parsePage))
+    where
+      pageConfig =
+        PageConfig
+          { defaultPageSize = fromInteger (natVal (Proxy @defSize)),
+            maxPageSize = fromInteger (natVal (Proxy @maxSize))
+          }
+
+      parsePage :: Request -> DelayedIO PageRequest
+      parsePage req = do
+        let qs = queryToQueryText (queryString req)
+            look n = join (lookup n qs) -- valueless "?first" counts as absent
+        mFirst <- traverse (parseSize "first") (look "first")
+        mLast <- traverse (parseSize "last") (look "last")
+        mAfter <- traverse (parseCursor "after") (look "after")
+        mBefore <- traverse (parseCursor "before") (look "before")
+        case mkPageRequest pageConfig mFirst mAfter mLast mBefore of
+          Right pr -> pure pr
+          Left err -> delayedFailFatal (pageRequestError400 mFirst err)
+
+      parseSize :: Text -> Text -> DelayedIO Int
+      parseSize param raw = case parseQueryParam raw of
+        Right n -> pure n
+        Left e ->
+          delayedFailFatal
+            (relayError400 "invalid_integer" ("invalid integer: " <> e) (Just param))
+
+      parseCursor :: Text -> Text -> DelayedIO Cursor
+      parseCursor param raw = case cursorFromText raw of
+        Right c -> pure c
+        Left e ->
+          delayedFailFatal
+            (relayError400 "invalid_cursor" ("invalid cursor: " <> e) (Just param))
+
+-- | The client- and link-side argument for a 'RelayPage' route: the four
+-- Relay parameters as one named record instead of four positional @Maybe@s
+-- (two adjacent @Maybe Int@ and two adjacent @Maybe Cursor@ positional
+-- arguments would be a swap-bug factory).
+--
+-- Prefer the smart constructors 'noPageArgs', 'forwardPage', and
+-- 'backwardPage' for the valid combinations. The raw constructor stays
+-- exported deliberately so tests can send invalid combinations and exercise
+-- the server's 400 path. Consume it with an explicit record pattern (a pun
+-- on the @last@ field would shadow 'Prelude.last').
+data ClientPage = ClientPage
+  { first :: !(Maybe Int),
+    after :: !(Maybe Cursor),
+    last :: !(Maybe Int),
+    before :: !(Maybe Cursor)
+  }
+  deriving stock (Eq, Show)
+
+-- | No pagination parameters: the server pages forward with its default size.
+noPageArgs :: ClientPage
+noPageArgs =
+  ClientPage {first = Nothing, after = Nothing, last = Nothing, before = Nothing}
+
+-- | Page forward: @first@ plus an optional @after@ cursor.
+forwardPage :: Int -> Maybe Cursor -> ClientPage
+forwardPage size mAfter =
+  ClientPage {first = Just size, after = mAfter, last = Nothing, before = Nothing}
+
+-- | Page backward: @last@ plus an optional @before@ cursor.
+backwardPage :: Int -> Maybe Cursor -> ClientPage
+backwardPage size mBefore =
+  ClientPage {first = Nothing, after = Nothing, last = Just size, before = mBefore}
+
+-- | Client functions take one 'ClientPage' and append whichever of the four
+-- parameters are present to the outgoing query string.
+instance (Client.HasClient m api) => Client.HasClient m (RelayPage d mx :> api) where
+  type Client m (RelayPage d mx :> api) = ClientPage -> Client.Client m api
+
+  clientWithRoute pm _ req page =
+    Client.clientWithRoute pm (Proxy @api) (addPageParams page req)
+
+  hoistClientMonad pm _ f cl = Client.hoistClientMonad pm (Proxy @api) f . cl
+
+addPageParams :: ClientPage -> Client.Request -> Client.Request
+addPageParams ClientPage {first = mFirst, after = mAfter, last = mLast, before = mBefore} =
+  add "before" mBefore . add "last" mLast . add "after" mAfter . add "first" mFirst
+  where
+    add :: (ToHttpApiData v) => Text -> Maybe v -> Client.Request -> Client.Request
+    add name =
+      maybe id (\v -> Client.appendToQueryString name (Just (Client.encodeQueryParamValue v)))
+
+-- | Links take the same 'ClientPage'; 'Servant.Links.safeLink' renders the
+-- present parameters in @first@, @after@, @last@, @before@ order.
+instance (HasLink sub) => HasLink (RelayPage d mx :> sub) where
+  type MkLink (RelayPage d mx :> sub) a = ClientPage -> MkLink sub a
+
+  toLink toA _ l page = toLink toA (Proxy @sub) (addPageLinkParams page l)
+
+addPageLinkParams :: ClientPage -> Link -> Link
+addPageLinkParams ClientPage {first = mFirst, after = mAfter, last = mLast, before = mBefore} =
+  add "before" mBefore . add "last" mLast . add "after" mAfter . add "first" mFirst
+  where
+    add :: (ToHttpApiData v) => String -> Maybe v -> Link -> Link
+    add name = maybe id (\v -> addQueryParam (SingleParam name (toQueryParam v)))
+
+-- | A 400 whose body is the JSON 'RelayPageError' assembled from the pieces.
+relayError400 :: Text -> Text -> Maybe Text -> ServerError
+relayError400 errorCode errorMessage offender =
+  err400
+    { errBody =
+        Aeson.encode
+          RelayPageError
+            { code = errorCode,
+              message = errorMessage,
+              retryable = False,
+              parameter = offender
+            },
+      errHeaders = [("Content-Type", "application/json")]
+    }
+
+-- | Map core's 'PageRequestError' to the wire contract. The @first@ argument
+-- disambiguates which size parameter a size error should blame.
+pageRequestError400 :: Maybe Int -> PageRequestError -> ServerError
+pageRequestError400 mFirst = \case
+  FirstAndLastBothGiven -> mixed "last"
+  AfterAndBeforeBothGiven -> mixed "before"
+  FirstWithBefore -> mixed "before"
+  LastWithAfter -> mixed "after"
+  NegativePageSize n ->
+    relayError400
+      "negative_page_size"
+      ("page size must be non-negative, got " <> tshow n)
+      (Just sizeParam)
+  PageSizeTooLarge {requested, allowedMax} ->
+    relayError400
+      "page_size_too_large"
+      ("page size " <> tshow requested <> " exceeds maximum " <> tshow allowedMax)
+      (Just sizeParam)
+  where
+    sizeParam = if isJust mFirst then "first" else "last"
+    mixed offender =
+      relayError400
+        "mixed_pagination_directions"
+        "cannot combine forward (first/after) and backward (last/before) arguments"
+        (Just offender)
+    tshow :: (Show a) => a -> Text
+    tshow = Text.pack . show
diff --git a/src/Relay/Pagination/Servant/OpenApi.hs b/src/Relay/Pagination/Servant/OpenApi.hs
new file mode 100644
--- /dev/null
+++ b/src/Relay/Pagination/Servant/OpenApi.hs
@@ -0,0 +1,124 @@
+{-# OPTIONS_GHC -Wno-orphans #-}
+
+-- | OpenAPI 3.1 documentation for the 'RelayPage' combinator, plus the
+-- schema instances for the core pagination types.
+--
+-- This module is the __canonical (sole permitted) home__ of the OpenAPI
+-- orphan instances for the core @relay-pagination@ types ('Cursor',
+-- 'PageInfo', 'Edge', 'Connection') and for 'RelayPageError'. The core
+-- package must not depend on @openapi-hs@, and @openapi-hs@ cannot know
+-- about this library, so orphans are the standard escape hatch — the danger
+-- of orphans is duplicate definitions elsewhere, therefore no other package
+-- in this repository or downstream may define these instances.
+--
+-- The 'HasOpenApi' instance documents the four Relay query parameters,
+-- including the type-level default and maximum page sizes. It deliberately
+-- does not add a synthetic 400 response: the terminal 'MultiVerb' declares
+-- @Respond 400 ... RelayPageError@ and @servant-openapi-hs@ derives the
+-- response from that type, which makes a plain @Get@ terminal visibly
+-- incomplete rather than letting documentation pretend its client can decode
+-- an error the route type omitted.
+module Relay.Pagination.Servant.OpenApi () where
+
+import Control.Lens ((&), (.~), (?~))
+import Data.Aeson (toJSON)
+import Data.OpenApi
+import Data.Proxy (Proxy (..))
+import Data.Text (Text)
+import Data.Text qualified as Text
+import GHC.TypeLits (KnownNat, natVal)
+import Relay.Pagination
+import Relay.Pagination.Servant
+import Servant.API ((:>))
+import Servant.OpenApi (HasOpenApi (..))
+import Servant.OpenApi.Internal (addParam)
+
+instance
+  (KnownNat d, KnownNat mx, HasOpenApi sub) =>
+  HasOpenApi (RelayPage d mx :> sub)
+  where
+  toOpenApi _ =
+    -- addParam prepends to each operation's parameter list, so apply in
+    -- reverse to document the parameters in first, after, last, before order.
+    toOpenApi (Proxy @sub)
+      & addParam beforeParam
+      & addParam lastParam
+      & addParam afterParam
+      & addParam firstParam
+    where
+      defSize, maxSize :: Integer
+      defSize = natVal (Proxy @d)
+      maxSize = natVal (Proxy @mx)
+
+      sizeSchema =
+        mempty
+          & type_ ?~ OpenApiTypeSingle OpenApiInteger
+          & minimum_ ?~ 0
+          & maximum_ ?~ fromInteger maxSize
+
+      cursorSchema = toParamSchema (Proxy @Cursor)
+
+      queryParam paramName paramDescription paramSchema =
+        mempty
+          & name .~ paramName
+          & in_ .~ ParamQuery
+          & required ?~ False
+          & description ?~ paramDescription
+          & schema ?~ Inline paramSchema
+
+      firstParam =
+        queryParam
+          "first"
+          ( "Forward page size (0.."
+              <> tshow maxSize
+              <> "). Defaults to "
+              <> tshow defSize
+              <> " when neither 'first' nor 'last' is given. "
+              <> "Cannot be combined with 'last' or 'before'."
+          )
+          (sizeSchema & default_ ?~ toJSON defSize)
+      lastParam =
+        queryParam
+          "last"
+          ( "Backward page size (0.."
+              <> tshow maxSize
+              <> "). Cannot be combined with 'first' or 'after'."
+          )
+          sizeSchema
+      afterParam =
+        queryParam
+          "after"
+          "Opaque cursor: return items after this position (forward pagination)."
+          cursorSchema
+      beforeParam =
+        queryParam
+          "before"
+          "Opaque cursor: return items before this position (backward pagination)."
+          cursorSchema
+
+      tshow :: (Show a) => a -> Text
+      tshow = Text.pack . show
+
+instance ToParamSchema Cursor where
+  toParamSchema _ =
+    mempty
+      & type_ ?~ OpenApiTypeSingle OpenApiString
+      & format ?~ "base64url"
+
+instance ToSchema Cursor where
+  declareNamedSchema _ =
+    pure . NamedSchema (Just "Cursor") $
+      mempty
+        & type_ ?~ OpenApiTypeSingle OpenApiString
+        & format ?~ "base64url"
+        & description ?~ "opaque pagination cursor"
+
+instance ToSchema RelayPageError
+
+instance ToSchema PageInfo
+
+instance (ToSchema a) => ToSchema (Edge a) where
+  declareNamedSchema = genericDeclareNamedSchema defaultSchemaOptions
+
+instance (ToSchema a) => ToSchema (Connection a) where
+  declareNamedSchema = genericDeclareNamedSchema defaultSchemaOptions
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,266 @@
+{-# LANGUAGE BlockArguments #-}
+
+module Main (main) where
+
+import Control.Lens ((^.), (^..))
+import Data.Aeson qualified as Aeson
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.ByteString.Lazy (ByteString)
+import Data.ByteString.Lazy qualified as LBS
+import Data.HashMap.Strict.InsOrd qualified as InsOrd
+import Data.List (sort)
+import Data.OpenApi qualified as OpenApi
+import Data.Proxy (Proxy (..))
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Network.HTTP.Client qualified as HttpClient
+import Network.HTTP.Types (statusCode)
+import Network.Wai.Handler.Warp (testWithApplication)
+import Relay.Pagination
+import Relay.Pagination.Servant
+import Relay.Pagination.Servant.OpenApi ()
+import Servant.Client (BaseUrl (..), ClientError, ClientM, Scheme (Http), mkClientEnv, runClientM)
+import Servant.Client.Generic (AsClientT, genericClient)
+import Servant.Links (safeLink)
+import Test.Tasty
+import Test.Tasty.HUnit
+import ToyApi
+import ToyOpenApi (renderToyOpenApi, toyOpenApi)
+import Web.HttpApiData (toUrlPiece)
+
+main :: IO ()
+main =
+  defaultMain $
+    testGroup
+      "relay-pagination-servant"
+      [serverTests, clientTests, linkTests, openapiTests]
+
+-- * The RelayPage HasServer instance, over real HTTP (M2)
+
+serverTests :: TestTree
+serverTests =
+  testGroup
+    "server"
+    [ withToy "applies default page size" "/items" \(status, body) -> do
+        status @?= 200
+        conn <- decodeOk @(Connection Item) body
+        firstItem conn @?= Item {itemId = 10, itemName = "forward"},
+      withToy "honors first=5" "/items?first=5" \(status, body) -> do
+        status @?= 200
+        conn <- decodeOk @(Connection Item) body
+        firstItem conn @?= Item {itemId = 5, itemName = "forward"},
+      withToy
+        "pages backward with last+before"
+        ("/items?last=5&before=" <> validCursorText)
+        \(status, body) -> do
+          status @?= 200
+          conn <- decodeOk @(Connection Item) body
+          firstItem conn @?= Item {itemId = 5, itemName = "backward"},
+      withToy "400 on malformed cursor" "/items?after=%25%25garbage" \(status, body) -> do
+        status @?= 400
+        err <- decodeOk @RelayPageError body
+        assertRelayError err "invalid_cursor" (Just "after"),
+      withToy "400 on first+last" "/items?first=5&last=5" \(status, body) -> do
+        status @?= 400
+        err <- decodeOk @RelayPageError body
+        assertRelayError err "mixed_pagination_directions" (Just "last"),
+      withToy "typed 400 on first over max" "/items?first=101" \(status, body) -> do
+        status @?= 400
+        err <- decodeOk @RelayPageError body
+        assertRelayError err "page_size_too_large" (Just "first"),
+      withToy "accepts first=0" "/items?first=0" \(status, body) -> do
+        status @?= 200
+        conn <- decodeOk @(Connection Item) body
+        firstItem conn @?= Item {itemId = 0, itemName = "forward"},
+      withToy "typed 400 on first=-1" "/items?first=-1" \(status, body) -> do
+        status @?= 400
+        err <- decodeOk @RelayPageError body
+        assertRelayError err "negative_page_size" (Just "first")
+    ]
+  where
+    -- The servant layer checks only base64url shape, so any well-formed
+    -- cursor text passes it; payload validation belongs to the hasql layer.
+    validCursorText = toUrlPiece (Cursor "anything")
+
+-- * The generated client, typed 200/400 round trips (M3)
+
+toyClient :: ToyRoutes (AsClientT ClientM)
+toyClient = genericClient
+
+clientTests :: TestTree
+clientTests =
+  testGroup
+    "client"
+    [ withToyClient "typed forward round trip" (forwardPage 7 Nothing) \result ->
+        case result of
+          Right (ToyPageOk conn) ->
+            -- The whole Connection, exactly as the raw-HTTP tests saw it:
+            -- this exercises core's FromJSON through servant-client.
+            conn
+              @?= Connection
+                { edges = [Edge {node = Item {itemId = 7, itemName = "forward"}, cursor = toyCursor}],
+                  pageInfo =
+                    PageInfo
+                      { hasNextPage = False,
+                        hasPreviousPage = False,
+                        startCursor = Just toyCursor,
+                        endCursor = Just toyCursor
+                      }
+                }
+          other -> assertFailure ("expected ToyPageOk, got " <> show other),
+      withToyClient "typed backward round trip" (backwardPage 3 (Just toyCursor)) \result ->
+        case result of
+          Right (ToyPageOk conn) ->
+            firstItem conn @?= Item {itemId = 3, itemName = "backward"}
+          other -> assertFailure ("expected ToyPageOk, got " <> show other),
+      withToyClient
+        "decodes mixed ClientPage as 400 sum"
+        ClientPage {first = Just 5, after = Nothing, last = Just 5, before = Nothing}
+        \result ->
+          case result of
+            Right (ToyPageBadRequest err) ->
+              -- Right, not Left FailureResponse: the practical reason the
+              -- route declares MultiVerb with a typed 400 alternative.
+              assertRelayError err "mixed_pagination_directions" (Just "last")
+            other -> assertFailure ("expected ToyPageBadRequest, got " <> show other)
+    ]
+
+withToyClient :: TestName -> ClientPage -> (Either ClientError ToyPageResult -> Assertion) -> TestTree
+withToyClient name page check =
+  testCase name $
+    testWithApplication (pure toyApp) \port -> do
+      manager <- HttpClient.newManager HttpClient.defaultManagerSettings
+      let env = mkClientEnv manager (BaseUrl Http "127.0.0.1" port "")
+      runClientM (items toyClient page) env >>= check
+
+-- * Typed links (M3)
+
+linkTests :: TestTree
+linkTests =
+  testGroup
+    "links"
+    [ testCase "renders first+after query string" $
+        toUrlPiece (safeLink toyApi (Proxy @ToyItemsEndpoint) (forwardPage 5 (Just (Cursor "YWI"))))
+          @?= "items?first=5&after=YWI",
+      testCase "noPageArgs renders bare path" $
+        toUrlPiece (safeLink toyApi (Proxy @ToyItemsEndpoint) noPageArgs)
+          @?= "items"
+    ]
+
+-- * The derived OpenAPI 3.1 document (M4)
+
+-- These tests are read-only: only the relay-demo-openapi executable may
+-- write the checked artifact. The test binary runs with the package
+-- directory as its working directory, hence the test-relative path.
+openapiTests :: TestTree
+openapiTests =
+  testGroup
+    "openapi"
+    [ testCase "checked artifact matches generator" $ do
+        checked <- LBS.readFile "test/golden/toy-openapi.json"
+        checked @?= renderToyOpenApi,
+      testCase "document version is OpenAPI 3.1" $
+        case Aeson.toJSON toyOpenApi of
+          Aeson.Object o -> KeyMap.lookup "openapi" o @?= Just (Aeson.String "3.1.0")
+          other -> assertFailure ("document did not serialize to an object: " <> show other),
+      testCase "path set is exactly /items with a getItems GET" $ do
+        InsOrd.keys (toyOpenApi ^. OpenApi.paths) @?= ["/items"]
+        theOperation ^. OpenApi.operationId @?= Just "getItems",
+      testCase "operation declares exactly the 200 and 400 responses" $
+        sort (InsOrd.keys (theOperation ^. OpenApi.responses . OpenApi.responses)) @?= [200, 400],
+      testCase "four query parameters in Relay order" $
+        map (^. OpenApi.name) inlineParams @?= ["first", "after", "last", "before"],
+      testCase "size parameters carry bounds, first carries the default" $ do
+        let sizeSchema p = do
+              OpenApi.minimum_ `is` Just 0 $ p
+              OpenApi.maximum_ `is` Just 100 $ p
+        mapM_ (withParam sizeSchema) ["first", "last"]
+        withParam (OpenApi.default_ `is` Just (Aeson.Number 10)) "first"
+        withParam (OpenApi.default_ `is` Nothing) "last",
+      testCase "cursor parameters are base64url strings" $
+        mapM_
+          ( withParam \s -> do
+              OpenApi.type_ `is` Just (OpenApi.OpenApiTypeSingle OpenApi.OpenApiString) $ s
+              OpenApi.format `is` Just "base64url" $ s
+          )
+          ["after", "before"],
+      testCase "components hold every envelope schema" $
+        sort (InsOrd.keys (toyOpenApi ^. OpenApi.components . OpenApi.schemas))
+          @?= ["Connection_Item", "Cursor", "Edge_Item", "Item", "PageInfo", "RelayPageError"],
+      testCase "representative Connection Item validates against its schema" $
+        OpenApi.validateToJSON exampleConnection @?= [],
+      testCase "representative RelayPageError validates against its schema" $
+        OpenApi.validateToJSON exampleError @?= []
+    ]
+  where
+    theOperation = case toyOpenApi ^.. OpenApi.allOperations of
+      [op] -> op
+      ops -> error ("expected exactly one operation, got " <> show (length ops))
+
+    inlineParams =
+      [p | OpenApi.Inline p <- theOperation ^. OpenApi.parameters]
+
+    withParam check paramName =
+      case [p | p <- inlineParams, p ^. OpenApi.name == paramName] of
+        [p] -> case p ^. OpenApi.schema of
+          Just (OpenApi.Inline s) -> check s
+          other -> assertFailure ("parameter " <> show paramName <> " has no inline schema: " <> show other)
+        ps -> assertFailure ("expected one parameter " <> show paramName <> ", got " <> show (length ps))
+
+    is l expected s = s ^. l @?= expected
+
+    exampleConnection :: Connection Item
+    exampleConnection =
+      Connection
+        { edges = [Edge {node = Item {itemId = 1, itemName = "alpha"}, cursor = toyCursor}],
+          pageInfo =
+            PageInfo
+              { hasNextPage = True,
+                hasPreviousPage = False,
+                startCursor = Just toyCursor,
+                endCursor = Just toyCursor
+              }
+        }
+
+    exampleError :: RelayPageError
+    exampleError =
+      RelayPageError
+        { code = "mixed_pagination_directions",
+          message = "cannot combine forward (first/after) and backward (last/before) arguments",
+          retryable = False,
+          parameter = Just "last"
+        }
+
+-- * Helpers
+
+-- | Run the toy app on a free port and hit it with one raw HTTP request, so
+-- malformed query strings reach the combinator verbatim.
+withToy :: TestName -> Text -> ((Int, ByteString) -> Assertion) -> TestTree
+withToy name pathAndQuery check =
+  testCase name $
+    testWithApplication (pure toyApp) \port -> do
+      manager <- HttpClient.newManager HttpClient.defaultManagerSettings
+      request <-
+        HttpClient.parseRequest
+          ("http://127.0.0.1:" <> show port <> Text.unpack pathAndQuery)
+      response <- HttpClient.httpLbs request manager
+      check
+        ( statusCode (HttpClient.responseStatus response),
+          HttpClient.responseBody response
+        )
+
+decodeOk :: forall a. (Aeson.FromJSON a) => ByteString -> IO a
+decodeOk body = case Aeson.eitherDecode body of
+  Right a -> pure a
+  Left e -> assertFailure ("body did not decode: " <> e <> "\nbody: " <> show body)
+
+firstItem :: Connection Item -> Item
+firstItem conn = case edges conn of
+  Edge {node} : _ -> node
+  [] -> error "expected at least one edge"
+
+assertRelayError :: RelayPageError -> Text -> Maybe Text -> Assertion
+assertRelayError err expectedCode expectedParameter = do
+  code err @?= expectedCode
+  parameter err @?= expectedParameter
+  retryable err @?= False
