SEO

Setting Page Titles

Every page on your site should have a unique, descriptive <title> tag. Search engines display this in their results, and it is one of the most important on-page SEO factors.

IHP provides a built-in system for managing page titles through the setTitle and pageTitle functions, which are available in every controller and view without any extra imports.

Setting Up the Layout

First, make sure your Web/View/Layout.hs uses pageTitleOrDefault inside the <head> tag. This renders the page title that has been set for the current request, falling back to a default if none was set:

defaultLayout :: Html -> Html
defaultLayout inner = [hsx|
<!DOCTYPE html>
<html>
    <head>
        <title>{pageTitleOrDefault "My App"}</title>
    </head>
    <body>
        {inner}
    </body>
</html>
|]

The string "My App" is the fallback title shown when no page-specific title has been set.

Setting a Title from a View

The most common approach is to call setTitle from the beforeRender hook of your view. This runs just before the layout wraps your HTML, so the layout can read the title you set:

module Web.View.Posts.Show where

import Web.View.Prelude

data ShowView = ShowView { post :: Post }

instance View ShowView where
    beforeRender ShowView { post } = do
        setTitle post.title

    html ShowView { post } = [hsx|
        <h1>{post.title}</h1>
        <p>{post.body}</p>
    |]

Setting a Title from a Controller Action

You can also call setTitle directly from a controller action. This is useful when the title depends on data you have already fetched:

instance Controller PostsController where
    action ShowPostAction { postId } = do
        post <- fetch postId
        setTitle post.title
        render ShowView { .. }

Setting an App-Wide Default Title

If you want every page in your application to start with a particular title (which individual views can then override), call setTitle from initContext in Web/FrontController.hs:

instance InitControllerContext WebApplication where
    initContext = do
        setLayout defaultLayout
        setTitle "My App - Built with IHP"

Any view that calls setTitle in its own beforeRender will override this default.

Meta Description

The meta description is a short summary of a page’s content. Search engines often display it as the snippet beneath the page title in results. Each page should have a unique, relevant description.

Setting Up the Layout

Add descriptionOrDefault to the <head> section of your layout:

defaultLayout :: Html -> Html
defaultLayout inner = [hsx|
<!DOCTYPE html>
<html>
    <head>
        <title>{pageTitleOrDefault "My App"}</title>
        {descriptionOrDefault "Default description of your website for search engines."}
    </head>
    <body>
        {inner}
    </body>
</html>
|]

This renders a <meta name="description" content="..."> tag. The string you pass is the fallback used when no page-specific description has been set.

Setting a Description Per Page

Call setDescription from the beforeRender hook of your view:

instance View ShowView where
    beforeRender ShowView { post } = do
        setTitle post.title
        setDescription post.summary

    html ShowView { .. } = [hsx|...|]

You can also call setDescription from a controller action, just like setTitle.

Open Graph Tags

Open Graph (OG) tags control how your pages appear when shared on social media platforms like Facebook, Twitter/X, and LinkedIn. Without these tags, social platforms will try to guess what to show – often with poor results.

IHP has built-in helpers for the most important OG tags: og:title, og:description, og:type, og:url, and og:image.

Setting Up the Layout

Add the OG helper functions to the <head> section of your layout:

defaultLayout :: Html -> Html
defaultLayout inner = [hsx|
<!DOCTYPE html>
<html>
    <head>
        <title>{pageTitleOrDefault "My App"}</title>
        {descriptionOrDefault "Default description of your website."}
        {ogTitleOrDefault "My App"}
        {ogTypeOrDefault "website"}
        {ogDescriptionOrDefault "Default description of your website."}
        {ogUrl}
        {ogImage}
    </head>
    <body>
        {inner}
    </body>
</html>
|]

The OrDefault variants render the meta tag with a fallback value. The ogUrl and ogImage helpers only render their meta tag when a value has been explicitly set – if you never call setOGUrl or setOGImage, no tag is output.

Setting OG Tags Per Page

Call the setter functions from beforeRender in your view:

instance View ShowView where
    beforeRender ShowView { post } = do
        setTitle post.title
        setDescription post.summary
        setOGTitle post.title
        setOGDescription post.summary
        setOGUrl (urlTo ShowPostAction { postId = post.id })

        case post.imageUrl of
            Just url -> setOGImage url
            Nothing -> pure ()

    html ShowView { .. } = [hsx|...|]

Available Setter Functions

FunctionRendersExample Value
setOGTitle<meta property="og:title" content="...">"My Blog Post"
setOGDescription<meta property="og:description" content="...">"A summary of the post"
setOGType<meta property="og:type" content="...">"article", "website", "product"
setOGUrl<meta property="og:url" content="...">"https://example.com/posts/123"
setOGImage<meta property="og:image" content="...">"https://example.com/images/post.jpg"

Twitter Card Tags

Twitter/X uses its own set of meta tags for link previews. These are not built into IHP, but you can add them manually in your layout or views using HSX:

defaultLayout :: Html -> Html
defaultLayout inner = [hsx|
<!DOCTYPE html>
<html>
    <head>
        <title>{pageTitleOrDefault "My App"}</title>
        {descriptionOrDefault "Default description."}
        {ogTitleOrDefault "My App"}
        {ogDescriptionOrDefault "Default description."}
        {ogUrl}
        {ogImage}
        <meta name="twitter:card" content="summary_large_image">
    </head>
    <body>
        {inner}
    </body>
</html>
|]

If you want per-page Twitter tags, you can use the same putContext/fromFrozenContext pattern described in the Views documentation for layout variables.

Canonical URLs

A canonical URL tells search engines which version of a page is the “official” one. This is important when the same content can be reached through multiple URLs (for example, with and without query parameters, or with different sorting options). Without a canonical tag, search engines might index duplicate pages and dilute your ranking.

Adding a Canonical Tag to the Layout

Since IHP does not have a built-in setCanonical helper, you can use the putContext/fromFrozenContext pattern to pass a canonical URL from your view to the layout.

First, create a newtype to store the canonical URL. You can add this to Web/View/Layout.hs or a shared module:

newtype CanonicalUrl = CanonicalUrl Text

In your layout, read the canonical URL from the context and render it if present:

defaultLayout :: Html -> Html
defaultLayout inner = [hsx|
<!DOCTYPE html>
<html>
    <head>
        <title>{pageTitleOrDefault "My App"}</title>
        {descriptionOrDefault "Default description."}
        {canonicalTag}
    </head>
    <body>
        {inner}
    </body>
</html>
|]

canonicalTag :: (?context :: ControllerContext) => Html
canonicalTag = case maybeFromFrozenContext @CanonicalUrl of
    Just (CanonicalUrl url) -> [hsx|<link rel="canonical" href={url}>|]
    Nothing -> mempty

Setting a Canonical URL from a Controller

Set the canonical URL by calling putContext in your action:

instance Controller PostsController where
    action ShowPostAction { postId } = do
        post <- fetch postId
        putContext (CanonicalUrl (urlTo ShowPostAction { postId = post.id }))
        render ShowView { .. }

This ensures that even if the page is accessed with extra query parameters like ?utm_source=twitter, search engines know the clean URL is the canonical one.

Sitemap

A sitemap is an XML file that lists all the important pages on your site. Search engines use it to discover and crawl your content more efficiently. IHP includes the ihp-sitemap package with built-in support for generating XML sitemaps.

Setting Up the Sitemap

First, create a new controller file Web/Controller/Sitemap.hs with the following contents:

module Web.Controller.Sitemap where

import Web.Controller.Prelude
import IHP.SEO.Sitemap.Types
import IHP.SEO.Sitemap.ControllerFunctions

instance Controller SitemapController where
    action SitemapAction = do
        -- Query all the posts
        posts <- query @Post |> fetch
        -- Build an `SitemapLink` for all posts
        let sitemapLinks = posts |> map (\post ->
                SitemapLink
                    { url = urlTo $ ShowPostAction post.id
                    , lastModified = Nothing
                    , changeFrequency = Just Hourly
                    })
        -- Render The Sitemap
        renderXmlSitemap (Sitemap sitemapLinks)

In your Web/Routes.hs module, import the IHP.SEO.Sitemap.Routes module:

module Web.Routes where
...
import IHP.SEO.Sitemap.Routes
...

Next, import the IHP.SEO.Sitemap.Types module in Web/FrontController.hs:

module Web.FrontController where
...
import IHP.SEO.Sitemap.Types
...

And then add parseRoute @SitemapController:

instance FrontController WebApplication where
    controllers =
        [ startPage WelcomeAction
        , parseRoute @SitemapController -- Add This Line
        -- Generator Marker
        ]

The SitemapController is configured by default to resolve /sitemap.xml routes.

Each SitemapLink has three fields:

FieldTypeDescription
urlTextThe full URL of the page. Use urlTo to generate it.
lastModifiedMaybe UTCTimeWhen the page was last updated. Use Nothing to omit.
changeFrequencyMaybe SitemapChangeFrequencyA hint for how often the page changes.

The available change frequencies are: Always, Hourly, Daily, Weekly, Monthly, Yearly, and Never.

Including Multiple Resource Types

A real sitemap usually includes links to several different types of pages:

instance Controller SitemapController where
    action SitemapAction = do
        posts <- query @Post |> fetch
        pages <- query @Page |> fetch

        let postLinks = posts |> map (\post ->
                SitemapLink
                    { url = urlTo $ ShowPostAction post.id
                    , lastModified = Just post.updatedAt
                    , changeFrequency = Just Weekly
                    })

        let pageLinks = pages |> map (\page ->
                SitemapLink
                    { url = urlTo $ ShowPageAction page.id
                    , lastModified = Just page.updatedAt
                    , changeFrequency = Just Monthly
                    })

        let homeLink = SitemapLink
                { url = urlTo WelcomeAction
                , lastModified = Nothing
                , changeFrequency = Just Daily
                }

        renderXmlSitemap (Sitemap (homeLink : postLinks <> pageLinks))

Customizing the Sitemap Route

If you need to customize the route, first, remove the IHP.SEO.Sitemap.Routes import from the Web.Routes module. And add the following:

module Web.Routes where
...
import IHP.SEO.Sitemap.Types -- Import The `SitemapController` Type
...

-- Here we customize the resolved route as `/custom-sitemap.xml`
instance HasPath SitemapController where
    pathTo SitemapAction = "/custom-sitemap.xml"

instance CanRoute SitemapController where
    parseRoute' = do
        string "/custom-sitemap.xml"
        endOfInput
        pure SitemapAction

robots.txt

The robots.txt file tells search engine crawlers which parts of your site they are allowed to crawl. It is also the standard place to point crawlers to your sitemap.

Using a Static File

The simplest approach is to place a robots.txt file in your project’s static/ directory. IHP serves files from static/ automatically, so the file will be available at https://yourdomain.com/robots.txt.

Create static/robots.txt:

User-agent: *
Allow: /

Sitemap: https://yourdomain.com/sitemap.xml

Replace https://yourdomain.com with your actual domain.

Serving robots.txt Dynamically

If you need the robots.txt content to change based on the environment (for example, disallowing crawling on staging), you can serve it from a controller action.

Add a route type and controller:

-- Web/Types.hs
data RobotsController = RobotsAction
    deriving (Eq, Show, Data)
-- Web/Routes.hs
instance HasPath RobotsController where
    pathTo RobotsAction = "/robots.txt"

instance CanRoute RobotsController where
    parseRoute' = do
        string "/robots.txt"
        endOfInput
        pure RobotsAction
-- Web/Controller/Robots.hs
module Web.Controller.Robots where

import Web.Controller.Prelude

instance Controller RobotsController where
    action RobotsAction = do
        let baseUrl = ?context.frameworkConfig.baseUrl
        renderPlain (cs $ "User-agent: *\nAllow: /\n\nSitemap: " <> baseUrl <> "/sitemap.xml\n")

Then register it in Web/FrontController.hs:

instance FrontController WebApplication where
    controllers =
        [ startPage WelcomeAction
        , parseRoute @RobotsController
        , parseRoute @SitemapController
        -- ...
        ]

This approach uses renderPlain to return a plain text response with the correct text/plain content type.

Structured Data (JSON-LD)

Structured data helps search engines understand the content of your pages. By adding JSON-LD (JavaScript Object Notation for Linked Data) to your pages, you can enable rich results in search – such as star ratings, breadcrumbs, FAQ accordions, and more.

Adding JSON-LD to a Page

JSON-LD is added as a <script type="application/ld+json"> tag in the page’s HTML. You can include it directly in your view using HSX:

instance View ShowView where
    html ShowView { post } = [hsx|
        <h1>{post.title}</h1>
        <p>{post.body}</p>

        <script type="application/ld+json">
            {preEscapedToHtml (renderJsonLd post)}
        </script>
    |]

renderJsonLd :: Post -> Text
renderJsonLd post = cs $ encode $ object
    [ "@context" .= ("https://schema.org" :: Text)
    , "@type" .= ("Article" :: Text)
    , "headline" .= post.title
    , "description" .= post.summary
    , "datePublished" .= post.createdAt
    , "dateModified" .= post.updatedAt
    ]

The encode function is from Data.Aeson, which is re-exported by the view prelude. The preEscapedToHtml function ensures the JSON is inserted into the page without HTML escaping.

Common Schema Types

Here are a few commonly used Schema.org types:

Refer to schema.org for the full list of types and their required properties. Google’s Rich Results Test tool can validate your structured data.

Organization Example for a Layout

If you want to include Organization structured data on every page, you can add it to your layout:

defaultLayout :: Html -> Html
defaultLayout inner = [hsx|
<!DOCTYPE html>
<html>
    <head>
        <title>{pageTitleOrDefault "My App"}</title>
        {descriptionOrDefault "Default description."}
        <script type="application/ld+json">
            {preEscapedToHtml organizationJsonLd}
        </script>
    </head>
    <body>
        {inner}
    </body>
</html>
|]

organizationJsonLd :: Text
organizationJsonLd = cs $ encode $ object
    [ "@context" .= ("https://schema.org" :: Text)
    , "@type" .= ("Organization" :: Text)
    , "name" .= ("My Company" :: Text)
    , "url" .= ("https://example.com" :: Text)
    ]

Bringing It All Together

Here is a complete layout example that incorporates all the SEO features described above:

-- Web/View/Layout.hs

module Web.View.Layout where

import Web.View.Prelude

newtype CanonicalUrl = CanonicalUrl Text

defaultLayout :: Html -> Html
defaultLayout inner = [hsx|
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>{pageTitleOrDefault "My App"}</title>
        {descriptionOrDefault "A web application built with IHP."}

        {ogTitleOrDefault "My App"}
        {ogTypeOrDefault "website"}
        {ogDescriptionOrDefault "A web application built with IHP."}
        {ogUrl}
        {ogImage}

        <meta name="twitter:card" content="summary_large_image">

        {canonicalTag}
    </head>
    <body>
        {inner}
    </body>
</html>
|]

canonicalTag :: (?context :: ControllerContext) => Html
canonicalTag = case maybeFromFrozenContext @CanonicalUrl of
    Just (CanonicalUrl url) -> [hsx|<link rel="canonical" href={url}>|]
    Nothing -> mempty

And here is a view that sets all the relevant SEO metadata for a page:

-- Web/View/Posts/Show.hs

module Web.View.Posts.Show where

import Web.View.Prelude
import Web.View.Layout (CanonicalUrl(..))

data ShowView = ShowView { post :: Post }

instance View ShowView where
    beforeRender ShowView { post } = do
        setTitle (post.title <> " - My App")
        setDescription post.summary
        setOGTitle post.title
        setOGDescription post.summary
        setOGUrl (urlTo ShowPostAction { postId = post.id })
        setOGType "article"

        case post.imageUrl of
            Just url -> setOGImage url
            Nothing -> pure ()

        putContext (CanonicalUrl (urlTo ShowPostAction { postId = post.id }))

    html ShowView { post } = [hsx|
        <h1>{post.title}</h1>
        <p>{post.body}</p>

        <script type="application/ld+json">
            {preEscapedToHtml (renderJsonLd post)}
        </script>
    |]

renderJsonLd :: (?context :: ControllerContext) => Post -> Text
renderJsonLd post = cs $ encode $ object
    [ "@context" .= ("https://schema.org" :: Text)
    , "@type" .= ("Article" :: Text)
    , "headline" .= post.title
    , "description" .= post.summary
    , "datePublished" .= post.createdAt
    , "dateModified" .= post.updatedAt
    ]