diff --git a/IHP/ServerSideComponent/Controller/ComponentsController.hs b/IHP/ServerSideComponent/Controller/ComponentsController.hs
new file mode 100644
--- /dev/null
+++ b/IHP/ServerSideComponent/Controller/ComponentsController.hs
@@ -0,0 +1,57 @@
+{-# LANGUAGE  UndecidableInstances #-}
+module IHP.ServerSideComponent.Controller.ComponentsController where
+
+import IHP.ControllerPrelude
+import IHP.ServerSideComponent.Types as SSC
+import IHP.ServerSideComponent.ControllerFunctions as SSC
+
+import qualified Data.Aeson as Aeson
+import qualified IHP.Log as Log
+import qualified Control.Exception as Exception
+import Data.Typeable (typeOf)
+
+instance (Component component controller, FromJSON controller, Typeable component) => WSApp (ComponentsController component) where
+    initialState = ComponentsController
+
+    run = do
+        let state :: component = SSC.initialState
+        instanceRef <- newIORef (ComponentInstance { state })
+        let ?instanceRef = instanceRef
+
+        let componentName = tshow (typeOf state)
+        Log.info ("SSC: Component " <> componentName <> " connected")
+
+        -- Handle componentDidMount with exception handling
+        mountResult <- Exception.try (componentDidMount state)
+        case mountResult of
+            Left (e :: SomeException) -> do
+                let errorText = tshow e
+                Log.error ("SSC: componentDidMount failed for " <> componentName <> ": " <> errorText)
+                SSC.sendError (SSCActionError { errorMessage = "Component initialization failed: " <> errorText })
+            Right nextState -> do
+                Log.debug ("SSC: Component " <> componentName <> " mounted")
+                SSC.setState nextState
+
+        forever do
+            actionPayload :: LByteString <- receiveData
+
+            let theAction = Aeson.eitherDecode @controller actionPayload
+
+            case theAction of
+                Right theAction -> do
+                    currentState <- SSC.getState
+
+                    -- Execute action with exception handling
+                    actionResult <- Exception.try (SSC.action currentState theAction)
+                    case actionResult of
+                        Left (e :: SomeException) -> do
+                            let errorText = tshow e
+                            Log.error ("SSC: Action failed for " <> componentName <> ": " <> errorText)
+                            SSC.sendError (SSCActionError { errorMessage = errorText })
+                        Right nextState -> do
+                            SSC.setState nextState
+                Left parseError -> do
+                    let errorText = cs parseError
+                    Log.error ("SSC: Failed parsing action for " <> componentName <> ": " <> errorText)
+                    Log.debug ("SSC: Invalid payload: " <> tshow actionPayload)
+                    SSC.sendError (SSCParseError { errorMessage = errorText })
diff --git a/IHP/ServerSideComponent/ControllerFunctions.hs b/IHP/ServerSideComponent/ControllerFunctions.hs
new file mode 100644
--- /dev/null
+++ b/IHP/ServerSideComponent/ControllerFunctions.hs
@@ -0,0 +1,58 @@
+{-# LANGUAGE TemplateHaskell #-}
+{-|
+Module: IHP.ServerSideComponent.ControllerFunctions
+Copyright: (c) digitally induced GmbH, 2021
+-}
+module IHP.ServerSideComponent.ControllerFunctions where
+
+import IHP.Prelude
+import IHP.ControllerPrelude
+import IHP.ServerSideComponent.Types as SSC
+
+import qualified Network.WebSockets as WebSocket
+import qualified Text.Blaze.Html.Renderer.Text as Blaze
+
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.TH as Aeson
+
+import IHP.ServerSideComponent.HtmlParser
+import IHP.ServerSideComponent.HtmlDiff
+
+import qualified IHP.Log as Log
+
+$(Aeson.deriveJSON Aeson.defaultOptions { sumEncoding = defaultTaggedObject { tagFieldName = "type" }} ''Attribute)
+$(Aeson.deriveJSON Aeson.defaultOptions { sumEncoding = defaultTaggedObject { tagFieldName = "type" }} ''AttributeOperation)
+$(Aeson.deriveJSON Aeson.defaultOptions { sumEncoding = defaultTaggedObject { tagFieldName = "type" }} ''Node)
+$(Aeson.deriveJSON Aeson.defaultOptions { sumEncoding = defaultTaggedObject { tagFieldName = "type" }} ''NodeOperation)
+$(Aeson.deriveJSON Aeson.defaultOptions { sumEncoding = defaultTaggedObject { tagFieldName = "type" }} ''SSCError)
+
+setState :: (?instanceRef :: IORef (ComponentInstance state), ?connection :: WebSocket.Connection, Component state action, ?context :: ControllerContext, ?request :: Request) => state -> IO ()
+setState state = do
+    oldState <- (.state) <$> readIORef ?instanceRef
+    let oldHtml = oldState
+            |> SSC.render
+            |> Blaze.renderHtml
+            |> cs
+    let newHtml = state
+            |> SSC.render
+            |> Blaze.renderHtml
+            |> cs
+
+    modifyIORef' ?instanceRef (\componentInstance -> componentInstance { state })
+
+    case diffHtml oldHtml newHtml of
+        Left parseError -> do
+            let errorText = tshow parseError
+            Log.error ("SSC HTML diff failed: " <> errorText)
+            sendError (SSCDiffError { errorMessage = errorText })
+        Right patches -> sendTextData (Aeson.encode patches)
+
+-- | Send an error message to the client
+sendError :: (?connection :: WebSocket.Connection) => SSCError -> IO ()
+sendError error = sendTextData (Aeson.encode error)
+
+
+getState :: (?instanceRef :: IORef (ComponentInstance state)) => IO state
+getState = (.state) <$> readIORef ?instanceRef
+
+deriveSSC = Aeson.deriveJSON Aeson.defaultOptions { allNullaryToStringTag = False, sumEncoding = defaultTaggedObject { tagFieldName = "action", contentsFieldName = "payload" }}
diff --git a/IHP/ServerSideComponent/HtmlDiff.hs b/IHP/ServerSideComponent/HtmlDiff.hs
new file mode 100644
--- /dev/null
+++ b/IHP/ServerSideComponent/HtmlDiff.hs
@@ -0,0 +1,131 @@
+{-|
+Module: IHP.ServerSideComponent.HtmlDiff
+Copyright: (c) digitally induced GmbH, 2021
+Description: Provides differences and patchsets between two HTML fragments
+-}
+module IHP.ServerSideComponent.HtmlDiff where
+
+import IHP.Prelude
+import IHP.ServerSideComponent.HtmlParser
+import qualified Data.Text as Text
+import Text.Megaparsec.Error (ParseErrorBundle)
+import Data.Void (Void)
+
+data NodeOperation
+    = UpdateTextContent { textContent :: !Text, path :: ![Int] }
+    | ReplaceNode { oldNode :: !Node, newNode :: !Node, newNodeHtml :: !Text, path :: ![Int] }
+    | UpdateNode { attributeOperations :: ![AttributeOperation], path :: ![Int] }
+    | UpdateComment { comment :: !Text, path :: ![Int] }
+    | UpdatePreEscapedTextNode { textContent :: !Text, path :: ![Int] }
+    | DeleteNode { node :: !Node, path :: ![Int] }
+    | CreateNode { html :: !Text, path :: ![Int] }
+    deriving (Eq, Show)
+
+data AttributeOperation
+    = UpdateAttribute { attributeName :: !Text, attributeValue :: !Text }
+    | AddAttribute { attributeName :: !Text, attributeValue :: !Text }
+    | DeleteAttribute { attributeName :: !Text }
+    deriving (Eq, Show)
+
+diffHtml :: Text -> Text -> Either (ParseErrorBundle Text Void) [NodeOperation]
+diffHtml a b = do
+    nodeA <- parseHtml a
+    nodeB <- parseHtml b
+
+    let ?oldHtml = a
+    let ?newHtml = b
+
+    pure (diffNodes nodeA nodeB)
+
+type Path = [Int]
+
+diffNodes :: (?oldHtml :: text, ?newHtml :: Text) => Node -> Node -> [NodeOperation]
+diffNodes = diffNodes' []
+
+diffNodes' :: (?oldHtml :: text, ?newHtml :: Text) => Path -> Node -> Node -> [NodeOperation]
+diffNodes' path TextNode { textContent = oldTextContent } TextNode { textContent = newTextContent } =
+        if oldTextContent == newTextContent
+            then []
+            else [UpdateTextContent { textContent = newTextContent, path }]
+diffNodes' path CommentNode { comment = oldComment } CommentNode { comment = newComment } =
+        if oldComment == newComment
+            then []
+            else [UpdateComment { comment = newComment, path }]
+diffNodes' path NoRenderCommentNode NoRenderCommentNode = []
+diffNodes' path PreEscapedTextNode { textContent = oldTextContent } PreEscapedTextNode { textContent = newTextContent } =
+        if oldTextContent == newTextContent
+            then []
+            else [UpdatePreEscapedTextNode { textContent = newTextContent, path }]
+diffNodes' path oldNode@(Node { tagName = oldTagName, attributes = oldAttributes, children = oldChildren }) newNode@(Node { tagName = newTagName, attributes = newAttributes, children = newChildren }) =
+    if oldTagName == newTagName
+        then
+            let
+                attributeOperations = diffAttributes oldAttributes newAttributes
+                childrenNodeOperations = diffNodes' path Children { children = oldChildren } Children { children = newChildren }
+            in concat [
+                    if isEmpty attributeOperations
+                        then []
+                        else [UpdateNode { attributeOperations, path }]
+                    , childrenNodeOperations
+                    ]
+
+        else [ReplaceNode { oldNode, newNode, newNodeHtml = nodeOuterHtml newNode ?newHtml, path }]
+diffNodes' path Children { children = oldChildren } Children { children = newChildren } =
+        let
+            patchElements :: [Node] -> [Node] -> Int -> [NodeOperation]
+            patchElements (new:nextNewNode:newRest) (old:oldRest) !index | (not $ new `isNodeEqIgnoringPosition` old) && (old `isNodeEqIgnoringPosition` nextNewNode) = [ CreateNode { html = nodeOuterHtml new ?newHtml, path = (index:path) } ] <> (patchElements (newRest) (oldRest) (index + 2)) -- [A, C <old>] -> [A, B <new>, C <nextNewNode>]
+            patchElements (new:newRest) (old:nextOld:oldRest) !index | (not $ new `isNodeEqIgnoringPosition` old) && (new `isNodeEqIgnoringPosition` nextOld) = [ DeleteNode { node = old, path = (index:path) } ] <> (patchElements (newRest) (oldRest) (index + 1)) -- [A, B <old>, C <nextOldNode> ] -> [A, C <new>]
+            patchElements (new:newRest) (old:oldRest) !index = (diffNodes' (index:path) old new) <> (patchElements newRest oldRest (index + 1))
+            patchElements (new:newRest) [] !index = [ CreateNode { html = nodeOuterHtml new ?newHtml, path = (index:path) } ] <> (patchElements newRest [] (index + 1))
+            patchElements [] (old:oldRest) !index = [ DeleteNode { node = old, path = (index:path) } ] <> (patchElements [] oldRest (index + 1))
+            patchElements [] [] _ = []
+        in
+            patchElements newChildren oldChildren 0
+diffNodes' path oldNode newNode = [ReplaceNode { oldNode, newNode, newNodeHtml = nodeOuterHtml newNode ?newHtml, path }]
+
+
+diffAttributes :: [Attribute] -> [Attribute] -> [AttributeOperation]
+diffAttributes old new = addOrUpdateAttributes <> deleteAttributes
+    where
+        addOrUpdateAttributes :: [AttributeOperation]
+        addOrUpdateAttributes =
+                new
+                |> map (matchAttribute old)
+                |> zip new
+                |> mapMaybe diffMatchedAttribute
+
+        deleteAttributes :: [AttributeOperation]
+        deleteAttributes =
+                old
+                |> map (matchAttribute new)
+                |> zip old
+                |> filter (\(_, newAttribute) -> isNothing newAttribute)
+                |> map (\(Attribute { attributeName }, _) -> DeleteAttribute { attributeName })
+
+        diffMatchedAttribute :: (Attribute, Maybe Attribute) -> Maybe AttributeOperation
+        diffMatchedAttribute (Attribute { attributeName, attributeValue = newValue }, Just Attribute { attributeValue = oldValue }) | newValue == oldValue = Nothing
+        diffMatchedAttribute (Attribute { attributeName, attributeValue = newValue }, Just Attribute { attributeValue = oldValue }) = Just UpdateAttribute { attributeName, attributeValue = newValue }
+        diffMatchedAttribute (Attribute { attributeName, attributeValue }, Nothing) = Just AddAttribute { attributeName, attributeValue }
+
+        -- | Finds an attribute in 'old' with the same attribute name
+        matchAttribute :: [Attribute] -> Attribute -> Maybe Attribute
+        matchAttribute attributes Attribute { attributeName } = find (\Attribute { attributeName = attributeName' } -> attributeName == attributeName' ) attributes
+
+-- | Grabs the entire HTML string corresponding to the node boundaries.
+--
+-- Node boundaries are only stored for 'Node'. Other nodes ('TextNode', etc) don't store start/end offset, so we render
+-- them by ourselves.
+nodeOuterHtml :: Node -> Text -> Text
+nodeOuterHtml Node { startOffset, endOffset } html = html
+        |> Text.drop startOffset
+        |> Text.take (endOffset - startOffset)
+-- Assuming chars are already escaped, because that's what HSX produces
+nodeOuterHtml TextNode { textContent } _ = textContent
+nodeOuterHtml PreEscapedTextNode { textContent } _ = textContent
+nodeOuterHtml Children { children } html = mconcat $ map (`nodeOuterHtml` html) children
+nodeOuterHtml CommentNode { comment } _ = "<!--" <> comment <> "-->"
+nodeOuterHtml NoRenderCommentNode _ = "" -- HSX comments {- ... -} are not rendered
+
+isNodeEqIgnoringPosition :: Node -> Node -> Bool
+isNodeEqIgnoringPosition a@(Node {}) b@(Node {}) = (a { startOffset = 0, endOffset = 0 }) == (b { startOffset = 0, endOffset = 0 })
+isNodeEqIgnoringPosition a b = a == b
diff --git a/IHP/ServerSideComponent/HtmlParser.hs b/IHP/ServerSideComponent/HtmlParser.hs
new file mode 100644
--- /dev/null
+++ b/IHP/ServerSideComponent/HtmlParser.hs
@@ -0,0 +1,134 @@
+{-|
+Module: IHP.ServerSideComponent.HtmlParser
+Copyright: (c) digitally induced GmbH, 2021
+Description: Used by serverside DOM diff
+-}
+module IHP.ServerSideComponent.HtmlParser
+( parseHtml
+, Node (..)
+, Attribute (..)
+) where
+
+import CorePrelude
+import Text.Megaparsec
+import Text.Megaparsec.Char
+import Data.Void
+import qualified Data.Char as Char
+import Data.String.Conversions
+
+data Attribute = Attribute
+    { attributeName :: !Text
+    , attributeValue :: !Text
+    } deriving (Eq, Show)
+
+data Node = Node { tagName :: !Text, attributes :: ![Attribute], children :: ![Node], startOffset :: Int, endOffset :: Int }
+    | TextNode { textContent :: !Text } -- ^ Note: doesn't unescape chars like &lt;
+    | PreEscapedTextNode { textContent :: !Text } -- ^ Used in @script@ or @style@ bodies
+    | Children { children :: ![Node] }
+    | CommentNode { comment :: !Text } -- ^ A Comment that is rendered in the final HTML
+    | NoRenderCommentNode -- ^ A comment that is not rendered in the final HTML
+    deriving (Eq, Show)
+
+parseHtml :: Text -> Either (ParseErrorBundle Text Void) Node
+parseHtml code = runParser (parser) "" code
+
+type Parser = Parsec Void Text
+
+parser :: Parser Node
+parser = do
+    space
+    node <- parseChildren <|> parseElement
+    space
+    eof
+    pure node
+
+parseElement = try parseNoRenderComment <|> try parseComment <|> try parseNormalElement <|> try parseSelfClosingElement
+
+parseChildren = Children <$> many parseChild
+
+parseSelfClosingElement = do
+    startOffset <- getOffset
+    _ <- char '<'
+    name <- parseElementName
+    attributes <- parseNodeAttributes (string ">" <|> string "/>")
+    endOffset <- getOffset
+    space
+    pure (Node name attributes [] startOffset endOffset)
+
+parseNormalElement = do
+    startOffset <- getOffset
+    (name, attributes) <- parseOpeningElement
+    let parsePreEscapedTextChildren = do
+                    let closingElement = "</" <> name <> ">"
+                    text <- cs <$> manyTill anySingle (string closingElement)
+                    pure [PreEscapedTextNode text]
+    let parseNormalChildren = (space >> (manyTill (try parseChild) (try (space >> parseClosingElement name))))
+
+    children <- case name of
+            "script" -> parsePreEscapedTextChildren
+            "style" -> parsePreEscapedTextChildren
+            otherwise -> parseNormalChildren
+    endOffset <- getOffset
+    pure (Node name attributes children startOffset endOffset)
+
+parseOpeningElement = do
+    char '<'
+    name <- parseElementName
+    space
+    attributes <- parseNodeAttributes (char '>')
+    pure (name, attributes)
+
+parseComment :: Parser Node
+parseComment = do
+    string "<!--"
+    body :: String <- manyTill (satisfy (const True)) (string "-->")
+    space
+    pure (CommentNode (cs body))
+
+parseNoRenderComment :: Parser Node
+parseNoRenderComment = do
+    string "{-"
+    manyTill (satisfy (const True)) (string "-}")
+    space
+    pure NoRenderCommentNode
+
+parseNodeAttributes :: Parser a -> Parser [Attribute]
+parseNodeAttributes end = manyTill parseNodeAttribute end
+
+parseNodeAttribute = do
+    attributeName <- parseAttributeName
+    space
+
+    let parseAttributeValue = do
+            _ <- char '='
+            space
+            attributeValue <- parseQuotedValue
+            space
+            pure attributeValue
+
+    attributeValue <- fromMaybe "" <$> optional parseAttributeValue
+    pure Attribute { attributeName, attributeValue }
+
+parseAttributeName :: Parser Text
+parseAttributeName = takeWhile1P Nothing (\c -> Char.isAlphaNum c || c == '-')
+
+parseQuotedValue :: Parser Text
+parseQuotedValue = between (char '"') (char '"') (takeWhileP Nothing (\c -> c /= '\"'))
+
+parseClosingElement name = do
+    _ <- string ("</" <> name)
+    space
+    char ('>')
+    pure ()
+
+parseChild = parseElement <|> try (parseElement) <|> parseText
+
+parseText :: Parser Node
+parseText = TextNode <$> takeWhile1P (Just "text") (\c -> c /= '<' && c /= '>')
+
+parseElementName :: Parser Text
+parseElementName = do
+    name <- takeWhile1P (Just "identifier") (\c -> Char.isAlphaNum c || c == '_' || c == '-')
+    space
+    pure name
+
diff --git a/IHP/ServerSideComponent/RouterFunctions.hs b/IHP/ServerSideComponent/RouterFunctions.hs
new file mode 100644
--- /dev/null
+++ b/IHP/ServerSideComponent/RouterFunctions.hs
@@ -0,0 +1,33 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-|
+Module: IHP.ServerSideComponent.RouterFunctions
+Copyright: (c) digitally induced GmbH, 2021
+-}
+module IHP.ServerSideComponent.RouterFunctions where
+
+import IHP.Prelude
+import IHP.ServerSideComponent.Types
+import qualified Data.Typeable as Typeable
+import qualified Data.ByteString.Char8 as ByteString
+import IHP.RouterSupport
+import qualified Prelude
+import IHP.ServerSideComponent.Controller.ComponentsController ()
+import Data.Aeson
+import IHP.ControllerSupport
+
+routeComponent :: forall component controller application.
+    ( Typeable component
+    , Component component controller
+    , FromJSON controller
+    , InitControllerContext application
+    , Typeable application
+    , ?application :: application
+    , ?request :: Request
+    , ?respond :: Respond
+    ) => ControllerRoute application
+routeComponent = webSocketAppWithCustomPath @(ComponentsController component) @application ("SSC/" <> typeName)
+    where
+        typeName :: ByteString
+        typeName = Typeable.typeOf (error "unreachable" :: component)
+                |> Prelude.show
+                |> ByteString.pack
diff --git a/IHP/ServerSideComponent/Types.hs b/IHP/ServerSideComponent/Types.hs
new file mode 100644
--- /dev/null
+++ b/IHP/ServerSideComponent/Types.hs
@@ -0,0 +1,45 @@
+{-|
+Module: IHP.ServerSideComponent.Types
+Description: Types & Data Structures for IHP SSC
+Copyright: (c) digitally induced GmbH, 2021
+-}
+module IHP.ServerSideComponent.Types where
+
+import IHP.ViewPrelude
+import qualified Network.WebSockets as WebSocket
+import GHC.Generics (Generic)
+
+class Component state action | state -> action where
+    initialState :: state
+    render :: state -> Html
+    action ::
+        ( ?instanceRef :: IORef (ComponentInstance state)
+        , ?connection :: WebSocket.Connection
+        , ?context :: ControllerContext
+        , ?modelContext :: ModelContext
+        ) => state -> action -> IO state
+
+    componentDidMount ::
+        ( ?instanceRef :: IORef (ComponentInstance state)
+        , ?connection :: WebSocket.Connection
+        , ?context :: ControllerContext
+        , ?modelContext :: ModelContext
+        ) => state -> IO state
+    componentDidMount state = pure state
+
+data ComponentsController components
+    = ComponentsController
+    deriving (Eq, Show, Data)
+
+data ComponentInstance state
+    = ComponentInstance { state :: state } -- ^ If you wondered why the current rendered HTML doesn't need to be stored here for later diffing it: As our render functions are pure we can just re-render the HTML based on the state when we want to do our diffing
+
+instance (SetField "state" (ComponentInstance state) state) where
+    setField state componentInstance = componentInstance { state }
+
+-- | Error types for SSC operations sent to the client
+data SSCError
+    = SSCDiffError { errorMessage :: !Text }
+    | SSCActionError { errorMessage :: !Text }
+    | SSCParseError { errorMessage :: !Text }
+    deriving (Eq, Show, Generic)
diff --git a/IHP/ServerSideComponent/ViewFunctions.hs b/IHP/ServerSideComponent/ViewFunctions.hs
new file mode 100644
--- /dev/null
+++ b/IHP/ServerSideComponent/ViewFunctions.hs
@@ -0,0 +1,24 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-|
+Module: IHP.ServerSideComponent.ViewFunctions
+Copyright: (c) digitally induced GmbH, 2021
+-}
+module IHP.ServerSideComponent.ViewFunctions where
+
+import IHP.Prelude
+import IHP.ViewSupport
+import IHP.ServerSideComponent.Types
+import IHP.HSX.QQ (hsx)
+
+import qualified Data.Typeable as Typeable
+
+component :: forall component action. (Component component action, Typeable component) => Html
+component = componentFromState (initialState @component)
+
+componentFromState :: forall component action. (Component component action, Typeable component) => component -> Html
+componentFromState state = [hsx|<div class="ihp-ssc" data-path={path}>{render state}</div>|]
+    where
+        path = "/SSC/" <> typeName
+        typeName = (undefined :: component)
+                |> Typeable.typeOf
+                |> show
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2020 digitally induced GmbH
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/changelog.md b/changelog.md
new file mode 100644
--- /dev/null
+++ b/changelog.md
@@ -0,0 +1,12 @@
+# Changelog for `ihp-ssc`
+
+## v1.5.0
+
+- Add `SSCError` type for structured error reporting (SSCDiffError, SSCActionError, SSCParseError)
+- Add proper error handling and logging in SSC controllers using IHP.Log
+- Fix missing `NoRenderCommentNode` pattern in `nodeOuterHtml`
+- Fix critical bugs and add reconnection support in ihp-ssc.js
+
+## v1.3.0
+
+- Initial release as a standalone package
diff --git a/ihp-ssc.cabal b/ihp-ssc.cabal
new file mode 100644
--- /dev/null
+++ b/ihp-ssc.cabal
@@ -0,0 +1,53 @@
+cabal-version:       2.2
+name:                ihp-ssc
+version:             1.5.0
+synopsis:            Server Side Components for IHP
+description:         IHP Server-Side Components provide a toolkit for building interactive client-side functionality without needing to write too much JavaScript.
+license:             MIT
+license-file:        LICENSE
+author:              digitally induced GmbH
+maintainer:          hello@digitallyinduced.com
+homepage:            https://ihp.digitallyinduced.com/
+bug-reports:         https://github.com/digitallyinduced/ihp/issues
+copyright:           (c) digitally induced GmbH
+category:            Web, IHP
+build-type:          Simple
+extra-source-files:  changelog.md
+
+source-repository head
+    type:     git
+    location: https://github.com/digitallyinduced/ihp
+
+library
+    default-language: GHC2021
+    default-extensions:
+        OverloadedStrings
+        , NoImplicitPrelude
+        , ImplicitParams
+        , DisambiguateRecordFields
+        , DuplicateRecordFields
+        , OverloadedLabels
+        , DataKinds
+        , QuasiQuotes
+        , TypeFamilies
+        , PackageImports
+        , RecordWildCards
+        , DefaultSignatures
+        , FunctionalDependencies
+        , PartialTypeSignatures
+        , BlockArguments
+        , LambdaCase
+        , TemplateHaskell
+        , OverloadedRecordDot
+        , DeepSubsumption
+    ghc-options: -Werror=incomplete-patterns -Werror=unused-imports -Werror=missing-fields
+    hs-source-dirs: .
+    build-depends: ihp, ihp-log, aeson, megaparsec, bytestring, wai, websockets, ihp-hsx, base >= 4.17.0 && < 4.22, string-conversions, basic-prelude, text, blaze-html, attoparsec, wai-request-params
+    exposed-modules:
+          IHP.ServerSideComponent.Types
+        , IHP.ServerSideComponent.ViewFunctions
+        , IHP.ServerSideComponent.RouterFunctions
+        , IHP.ServerSideComponent.ControllerFunctions
+        , IHP.ServerSideComponent.Controller.ComponentsController
+        , IHP.ServerSideComponent.HtmlDiff
+        , IHP.ServerSideComponent.HtmlParser
