diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,12 @@
 # ChangeLog for yesod-form
 
+## 1.7.10
+
+* Add `runFormPRG`, a `runFormPost` variant for the
+  Post/Redirect/Get pattern: failed submissions are stashed in the session
+  and replayed (input and validation errors) by the next GET.
+  [#1927](https://github.com/yesodweb/yesod/pull/1927)
+
 ## 1.7.9.3
 
 * Support `yesod-core` 1.7
diff --git a/Yesod/Form/Functions.hs b/Yesod/Form/Functions.hs
--- a/Yesod/Form/Functions.hs
+++ b/Yesod/Form/Functions.hs
@@ -32,6 +32,8 @@
     , runFormPost
     , runFormPostNoToken
     , runFormGet
+      -- ** Post\/Redirect\/Get
+    , runFormPRG
       -- * Generate a blank form
     , generateFormPost
     , generateFormGet'
@@ -67,16 +69,19 @@
 import Control.Monad.Trans.Class
 import Control.Monad.Trans.RWS (ask, get, put, runRWST, tell, evalRWST, local, mapRWST)
 import Control.Monad.Trans.Writer (runWriterT, writer)
-import Control.Monad (liftM, join)
+import Control.Monad (liftM, join, when)
+import qualified Data.Aeson as A
 import Data.Byteable (constEqBytes)
+import qualified Data.ByteString.Lazy as BL
 import Text.Blaze (Markup, toMarkup)
 #define Html Markup
 #define toHtml toMarkup
 import Yesod.Core
-import Network.Wai (requestMethod)
+import Network.Wai (Request, requestMethod, rawPathInfo)
 import Data.Maybe (listToMaybe, fromMaybe)
 import qualified Data.Map as Map
 import qualified Data.Text.Encoding as TE
+import qualified Data.Text.Encoding.Error as TEE
 import Control.Arrow (first)
 
 -- | Get a unique identifier.
@@ -356,6 +361,146 @@
     => (Html -> MForm m (FormResult a, xml))
     -> m (xml, Enctype)
 generateFormPost form = first snd `liftM` postHelper form Nothing
+
+-- | Run a form for the Post\/Redirect\/Get pattern, so that invalid input
+-- and its validation errors survive a redirect.
+--
+-- Use this in /both/ the GET and POST handlers of a route, exactly as you
+-- would 'runFormPost':
+--
+-- * On a POST request this behaves exactly like 'runFormPost' (including
+--   CSRF protection). Additionally, on 'FormFailure' the submitted
+--   parameters are stashed in the user session so that your handler can
+--   simply 'redirect' back to the GET route instead of re-rendering. On
+--   'FormSuccess' any previously stashed parameters for this route are
+--   cleared. On 'FormMissing' (another 'identifyForm'-wrapped form on the
+--   page was the one submitted) the stash is left untouched.
+--
+-- * On a GET request, if stashed parameters exist for the current path,
+--   they are removed from the session and the form is run against them:
+--   the fields re-render with the user's input and validation errors, just
+--   as a non-redirecting handler would have rendered them. A fresh CSRF
+--   token is rendered into the form (the stashed token is discarded, and
+--   no token check is performed on a replay). If nothing is stashed, this
+--   behaves like 'generateFormPost' (the result is 'FormMissing').
+--
+-- > getRegisterR :: Handler Html
+-- > getRegisterR = do
+-- >     ((_res, widget), enctype) <- runFormPRG personForm
+-- >     defaultLayout
+-- >         [whamlet|
+-- >             <form method=post action=@{RegisterR} enctype=#{enctype}>
+-- >                 ^{widget}
+-- >         |]
+-- >
+-- > postRegisterR :: Handler Html
+-- > postRegisterR = do
+-- >     ((res, _widget), _enctype) <- runFormPRG personForm
+-- >     case res of
+-- >         FormSuccess person -> do
+-- >             -- insert into the database, etc.
+-- >             setMessage "Registered!"
+-- >             redirect RegisterR
+-- >         _ -> redirect RegisterR
+--
+-- Notes and caveats:
+--
+-- * The stash is keyed by request path, so the GET and POST handlers must
+--   share a route (the common Yesod pattern). Form pages on different
+--   routes do not interfere with each other; two tabs on the /same/ path
+--   share one stash (the last failed submission wins).
+--
+-- * Multiple forms on one page work when wrapped with 'identifyForm': on
+--   replay, only the form that was actually submitted re-renders with
+--   values and errors; the others return 'FormMissing' and render blank.
+--   (The stash is popped once per request and shared between calls via
+--   the per-request cache, so every form in the handler sees it.)
+--
+-- * Uploaded files are not stashed; file fields re-render empty after the
+--   redirect.
+--
+-- * The stashed parameters live in the user session. With the default
+--   client-session backend the whole session must fit in a cookie
+--   (roughly 4KB), so this is unsuitable for very large forms. If the
+--   stash fails to decode, it is discarded and a blank form is generated.
+--
+-- * If the application has no session backend, stashing is a no-op and
+--   this degrades to 'runFormPost'\/'generateFormPost' semantics (input
+--   will not survive the redirect).
+--
+-- * If your POST handler re-renders on failure instead of redirecting, do
+--   not use this function: the stash would be consumed by an unrelated
+--   later GET of the same path.
+--
+-- * On a replay the form may even produce 'FormSuccess' (e.g. a 'checkM'
+--   validation whose outcome depends on external state); the result is
+--   returned as-is. GET handlers normally use only the widget.
+--
+-- * A HEAD request reads the stash without consuming it, so that
+--   middleware such as @autoHead@ does not eat the replay meant for the
+--   subsequent GET.
+--
+-- @since 1.7.10
+runFormPRG
+    :: (RenderMessage (HandlerSite m) FormMessage, MonadResource m, MonadHandler m)
+    => (Html -> MForm m (FormResult a, xml))
+    -> m ((FormResult a, xml), Enctype)
+runFormPRG form = do
+    req <- getRequest
+    let method = requestMethod $ reqWaiRequest req
+        key = prgSessionKey $ reqWaiRequest req
+    if method == "GET" || method == "HEAD"
+        then do
+            -- The per-request cache lets several calls in one handler
+            -- (multiple forms on a page) all see the stash, even though it
+            -- is deleted from the session on first read.
+            PRGStash menv <- cachedBy (TE.encodeUtf8 key) $ do
+                mbs <- lookupSessionBS key
+                when (method /= "HEAD") $ deleteSession key
+                return $ PRGStash $ mbs >>= A.decodeStrict
+            case menv of
+                Nothing -> postHelper form Nothing
+                Just env -> replayHelper form env
+        else do
+            env <- postEnv
+            res@((formRes, _), _) <- postHelper form env
+            case (formRes, env) of
+                (FormSuccess{}, _) -> deleteSession key
+                (FormFailure{}, Just (params, _)) -> setSessionBS key $
+                    BL.toStrict $ A.encode $
+                    Map.delete defaultCsrfParamName params
+                -- FormMissing means another identified form on this page
+                -- was the one submitted; leave its stash alone.
+                _ -> return ()
+            return res
+
+-- | Per-request cache wrapper for the Post\/Redirect\/Get stash; see
+-- 'runFormPRG'.
+newtype PRGStash = PRGStash (Maybe Env)
+
+-- | Like 'postHelper', but runs the form against a supplied 'Env' (with an
+-- empty 'FileEnv') instead of the current request body. A fresh CSRF token
+-- fragment is rendered into the form, and no token check is performed: the
+-- replayed submission was already checked by 'postHelper' when it was
+-- stashed, and this run is a re-render, not a submission.
+replayHelper :: MonadHandler m
+             => (Html -> MForm m a)
+             -> Env
+             -> m (a, Enctype)
+replayHelper form env = do
+    req <- getRequest
+    let token =
+            case reqToken req of
+                Nothing -> mempty
+                Just n -> [shamlet|<input type=hidden name=#{defaultCsrfParamName} value=#{n}>|]
+    m <- getYesod
+    langs <- languages
+    runFormGeneric (form token) m langs (Just (env, Map.empty))
+
+-- | Session key for the Post\/Redirect\/Get stash, namespaced by request
+-- path so that form pages on different routes do not interfere.
+prgSessionKey :: Request -> Text
+prgSessionKey req = "_PRG:" <> TE.decodeUtf8With TEE.lenientDecode (rawPathInfo req)
 
 postEnv :: MonadHandler m => m (Maybe (Env, FileEnv))
 postEnv = do
diff --git a/test/PRGSpec.hs b/test/PRGSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/PRGSpec.hs
@@ -0,0 +1,243 @@
+{-# LANGUAGE FlexibleContexts      #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings     #-}
+{-# LANGUAGE QuasiQuotes           #-}
+{-# LANGUAGE TemplateHaskell       #-}
+{-# LANGUAGE TypeFamilies          #-}
+{-# LANGUAGE ViewPatterns          #-}
+
+-- | Integration tests for 'runFormPRG'.
+module PRGSpec (spec) where
+
+import qualified Data.ByteString.Lazy as BL
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as TE
+import Network.Wai.Test (simpleBody)
+import Test.Hspec (Spec)
+import qualified Web.ClientSession as CS
+import Yesod.Core
+import Yesod.Form
+import Yesod.Test
+
+data App = App
+
+mkYesod "App" [parseRoutes|
+/form FormR GET POST
+/two TwoR GET POST
+/corrupt CorruptR GET
+/stale StaleR GET
+|]
+
+instance Yesod App where
+    -- A random key per test run, so no client_session_key.aes file is
+    -- written into the repository.
+    makeSessionBackend _ = do
+        key <- snd `fmap` CS.randomKey
+        (getCachedDate, _closeDateCacher) <- clientSessionDateCacher (120 * 60)
+        return $ Just $ clientSessionBackend key getCachedDate
+
+instance RenderMessage App FormMessage where
+    renderMessage _ _ = defaultFormMessage
+
+fs :: Text -> FieldSettings App
+fs name = FieldSettings (SomeMessage name) Nothing Nothing (Just name) []
+
+personForm :: Html -> MForm Handler (FormResult (Text, Int), Widget)
+personForm csrf = do
+    (nameRes, nameView) <- mreq textField (fs "name") Nothing
+    (ageRes, ageView) <- mreq ageField (fs "age") Nothing
+    let widget = [whamlet|
+            #{csrf}
+            ^{fvInput nameView}
+            $maybe err <- fvErrors nameView
+                <p .error>#{err}
+            ^{fvInput ageView}
+            $maybe err <- fvErrors ageView
+                <p .error>#{err}
+            |]
+    return ((,) <$> nameRes <*> ageRes, widget)
+  where
+    ageField = checkBool (>= (18 :: Int)) ("Too young" :: Text) intField
+
+-- | A one-field form for the two-forms-on-a-page tests; submitting
+-- non-numeric input fails with the input preserved.
+numForm :: Text -> Html -> MForm Handler (FormResult Int, Widget)
+numForm name csrf = do
+    (res, view) <- mreq intField (fs name) Nothing
+    let widget = [whamlet|
+            #{csrf}
+            ^{fvInput view}
+            $maybe err <- fvErrors view
+                <p .error>#{name}: #{err}
+            |]
+    return (res, widget)
+
+getFormR :: Handler Html
+getFormR = do
+    mmsg <- getMessage
+    ((_res, widget), enctype) <- runFormPRG personForm
+    defaultLayout [whamlet|
+        $maybe msg <- mmsg
+            <p .message>#{msg}
+        <form method=post action=@{FormR} enctype=#{enctype}>
+            ^{widget}
+            <button>go
+    |]
+
+postFormR :: Handler Html
+postFormR = do
+    ((res, _widget), _enctype) <- runFormPRG personForm
+    case res of
+        FormSuccess _ -> setMessage "registered" >> redirect FormR
+        _ -> redirect FormR
+
+getTwoR :: Handler Html
+getTwoR = do
+    mmsg <- getMessage
+    ((_, fooW), fooE) <- runFormPRG $ identifyForm "foo" $ numForm "foonum"
+    ((_, barW), barE) <- runFormPRG $ identifyForm "bar" $ numForm "barnum"
+    defaultLayout [whamlet|
+        $maybe msg <- mmsg
+            <p .message>#{msg}
+        <form method=post action=@{TwoR} enctype=#{fooE}>
+            ^{fooW}
+            <button>foo
+        <form method=post action=@{TwoR} enctype=#{barE}>
+            ^{barW}
+            <button>bar
+    |]
+
+postTwoR :: Handler Html
+postTwoR = do
+    ((fooRes, _), _) <- runFormPRG $ identifyForm "foo" $ numForm "foonum"
+    ((barRes, _), _) <- runFormPRG $ identifyForm "bar" $ numForm "barnum"
+    case (fooRes, barRes) of
+        (FormSuccess _, _) -> setMessage "foo ok" >> redirect TwoR
+        (_, FormSuccess _) -> setMessage "bar ok" >> redirect TwoR
+        _ -> redirect TwoR
+
+-- | Plant garbage bytes where the stash for \/form lives.
+getCorruptR :: Handler ()
+getCorruptR = setSessionBS "_PRG:/form" "definitely not json"
+
+-- | Plant a decodable stash for \/form, as if a submission with
+-- name=stale had failed earlier.
+getStaleR :: Handler ()
+getStaleR = setSessionBS "_PRG:/form" "{\"name\":[\"stale\"],\"age\":[\"12\"]}"
+
+-- | The CSRF token stays constant for a session, so grab it once from the
+-- current response body and reuse it across requests. This avoids
+-- 'addToken', which can only read the token from the /previous/ response.
+extractToken :: YesodExample App Text
+extractToken = do
+    mres <- getResponse
+    let body = maybe "" (TE.decodeUtf8 . BL.toStrict . simpleBody) mres
+        (_, rest) = T.breakOn "name=\"_token\" value=\"" body
+    case T.splitOn "\"" (T.drop (T.length "name=\"_token\" value=\"") rest) of
+        (token:_) | not (T.null token) -> return token
+        _ -> error "no _token in response body"
+
+postParams :: Route App -> Text -> [(Text, Text)] -> YesodExample App ()
+postParams route token params = request $ do
+    setMethod "POST"
+    setUrl route
+    addPostParam "_token" token
+    mapM_ (uncurry addPostParam) params
+
+spec :: Spec
+spec = yesodSpec App $
+    ydescribe "runFormPRG" $ do
+        yit "renders a blank form when nothing is stashed" $ do
+            get FormR
+            statusIs 200
+            bodyContains "name=\"name\""
+            bodyNotContains "error"
+
+        yit "replays invalid input and errors after the redirect, once" $ do
+            get FormR
+            token <- extractToken
+            postParams FormR token [("name", "alice"), ("age", "12")]
+            statusIs 303
+            get FormR
+            statusIs 200
+            bodyContains "value=\"alice\""
+            bodyContains "value=\"12\""
+            bodyContains "Too young"
+            get FormR
+            statusIs 200
+            bodyNotContains "alice"
+            bodyNotContains "Too young"
+
+        yit "does not stash on success" $ do
+            get FormR
+            token <- extractToken
+            postParams FormR token [("name", "alice"), ("age", "30")]
+            statusIs 303
+            get FormR
+            statusIs 200
+            bodyContains "registered"
+            bodyNotContains "value=\"alice\""
+
+        yit "success clears a stale stash" $ do
+            get FormR
+            token <- extractToken
+            get StaleR
+            postParams FormR token [("name", "alice"), ("age", "30")]
+            statusIs 303
+            get FormR
+            statusIs 200
+            bodyContains "registered"
+            bodyNotContains "stale"
+
+        yit "still enforces CSRF on POST" $ do
+            get FormR
+            postParams FormR "wrong-token" [("name", "alice"), ("age", "30")]
+            statusIs 303
+            get FormR
+            statusIs 200
+            bodyNotContains "registered"
+            bodyContains "value=\"alice\""
+
+        yit "replays only the submitted one of two identified forms" $ do
+            get TwoR
+            token <- extractToken
+            postParams TwoR token
+                [("_formid", "identify-foo"), ("foonum", "foo-banana")]
+            statusIs 303
+            get TwoR
+            statusIs 200
+            bodyContains "value=\"foo-banana\""
+            bodyContains "foonum: "
+            bodyNotContains "barnum: "
+
+        yit "replays the second form on the page too" $ do
+            get TwoR
+            token <- extractToken
+            postParams TwoR token
+                [("_formid", "identify-bar"), ("barnum", "bar-banana")]
+            statusIs 303
+            get TwoR
+            statusIs 200
+            bodyContains "value=\"bar-banana\""
+            bodyContains "barnum: "
+            bodyNotContains "foonum: "
+
+        yit "treats an undecodable stash as absent" $ do
+            get CorruptR
+            statusIs 200
+            get FormR
+            statusIs 200
+            bodyContains "name=\"name\""
+
+        yit "keys the stash by path" $ do
+            get FormR
+            token <- extractToken
+            postParams FormR token [("name", "alice"), ("age", "12")]
+            statusIs 303
+            get TwoR
+            statusIs 200
+            bodyNotContains "alice"
+            get FormR
+            statusIs 200
+            bodyContains "value=\"alice\""
diff --git a/test/main.hs b/test/main.hs
--- a/test/main.hs
+++ b/test/main.hs
@@ -6,8 +6,11 @@
 import Yesod.Form.Fields (parseTime)
 import Yesod.Form.Types
 
+import qualified PRGSpec
+
 main :: IO ()
-main = hspec $
+main = hspec $ do
+    PRGSpec.spec
     describe "parseTime" $ mapM_ (\(s, e) -> it s $ parseTime (pack s) `shouldBe` e)
         [ ("01:00:00", Right $ TimeOfDay 1 0 0)
         , ("1:00", Right $ TimeOfDay 1 0 0)
diff --git a/yesod-form.cabal b/yesod-form.cabal
--- a/yesod-form.cabal
+++ b/yesod-form.cabal
@@ -1,6 +1,6 @@
 cabal-version:   >= 1.10
 name:            yesod-form
-version:         1.7.9.3
+version:         1.7.10
 license:         MIT
 license-file:    LICENSE
 author:          Michael Snoyman <michael@snoyman.com>
@@ -77,11 +77,18 @@
     type: exitcode-stdio-1.0
     main-is: main.hs
     hs-source-dirs: test
+    other-modules: PRGSpec
     build-depends:          base
                           , yesod-form
                           , time
                           , hspec
                           , text
+                          , bytestring
+                          , clientsession
+                          , containers
+                          , wai-extra
+                          , yesod-core            >= 1.6
+                          , yesod-test            >= 1.6
 
 source-repository head
   type:     git
