Skip to main content

Creating Your First Project

This comprehensive tutorial will guide you through building a blog application with IHP. You’ll learn about database schemas, controllers, views, and relationships - all the fundamentals of IHP development.

What You’ll Build

By the end of this tutorial, you’ll have:
  • A blog with posts (title, body, and timestamps)
  • Markdown support for post content
  • Comments on posts
  • Full CRUD (Create, Read, Update, Delete) operations
  • Form validation
  • Beautiful URLs and routing

1. Project Setup

1

Create the Blog Project

ihp-new blog
cd blog
First Time Setup: The first time you run this, it might take 10-15 minutes. Subsequent projects will be much faster as packages are cached.
Windows Users: Make sure you’re in the Linux part of the filesystem (not on /mnt/), otherwise PostgreSQL will have issues.
2

Start the Development Server

devenv up
Your app will be available at http://localhost:8000 and development tools at http://localhost:8001.

2. Database Schema

Design the Posts Table

Let’s create a posts table for our blog. Each post will have:
  • An id (UUID, automatically generated)
  • A title (text)
  • A body (text)
1

Open the Schema Designer

2

Create the Posts Table

  1. Right-click and select Add Table
  2. Enter posts as the table name
  3. Click Create Table
The id column is automatically created with UUID type.
3

Add the Title Column

  1. Right-click in the Columns pane
  2. Select Add Column
  3. Name: title, Type: TEXT
  4. Click Create Column
4

Add the Body Column

Repeat the process for body (also TEXT type)
5

Migrate the Database

Click Migrate DBRun Migration to apply changes to your local database

Understanding the Generated Code

IHP automatically generates Haskell types for your schema. View the generated code in build/Generated/Types.hs:
data Post = Post 
    { id :: Id Post
    , title :: Text
    , body :: Text
    }

newtype Id Post = Id UUID
The Schema Designer is just a GUI for editing Application/Schema.sql. You can always edit this file directly if you prefer.

Verify the Schema

Check that the table was created:
make psql
SELECT * FROM posts;
-- Should show empty table with id, title, body columns
\q

3. Generate the Controller

1

Use the Code Generator

In the Schema Designer, right-click on the posts table and select Generate Controller.Alternatively, go to http://localhost:8001/Generators and click Controller.
2

Preview and Generate

  1. Enter Posts as the controller name
  2. Click Preview to see what will be created
  3. Click Generate
3

View Your New Controller

Open http://localhost:8000/Posts to see the generated CRUD interface.

What Was Generated?

The generator created: Web/Types.hs - Action types:
data PostsController
    = PostsAction                           -- GET /Posts
    | NewPostAction                          -- GET /NewPost  
    | ShowPostAction { postId :: !(Id Post) } -- GET /ShowPost?postId=...
    | CreatePostAction                       -- POST /CreatePost
    | EditPostAction { postId :: !(Id Post) } -- GET /EditPost?postId=...
    | UpdatePostAction { postId :: !(Id Post) } -- POST /UpdatePost?postId=...
    | DeletePostAction { postId :: !(Id Post) } -- DELETE /DeletePost?postId=...
Web/Controller/Posts.hs - Controller logic:
action PostsAction = do
    posts <- query @Post |> fetch
    render IndexView { .. }

action ShowPostAction { postId } = do
    post <- fetch postId
    render ShowView { .. }
Web/View/Posts/ - View templates using HSX (JSX-like syntax):
instance View ShowView where
    html ShowView { .. } = [hsx|
        <h1>Show Post</h1>
        <p>{post}</p>
    |]

4. Customize the Views

Create Your First Post

  1. Go to http://localhost:8000/Posts
  2. Click + New
  3. Enter:
    • Title: Hello World!
    • Body: Lorem ipsum dolor sit amet, consetetur sadipscing elitr
  4. Click Create Post

Improve the Show View

Edit Web/View/Posts/Show.hs:
Web/View/Posts/Show.hs
instance View ShowView where
    html ShowView { .. } = [hsx|
        {breadcrumb}
        <h1>{post.title}</h1>
        <p>{post.body}</p>
    |]
        where
            breadcrumb = renderBreadcrumb
                [ breadcrumbLink "Posts" PostsAction
                , breadcrumbText "Show Post"
                ]

Make the Index View Clickable

Edit Web/View/Posts/Index.hs:
<td><a href={ShowPostAction post.id}>{post.title}</a></td>
Remove the separate “Show” link and its corresponding <th> tag.

5. Add Validation

Let’s ensure every post has a title. Edit Web/Controller/Posts.hs:
Web/Controller/Posts.hs
buildPost post = post
    |> fill @["title","body"]
    |> validateField #title nonEmpty
Now try to create a post without a title - you’ll see a validation error!
See all available validators in the API Documentation.

6. Add Timestamps

1

Add created_at Column

In the Schema Designer:
  1. Right-click on posts table → Add Column to Table
  2. Name: created_at
  3. Type: TIMESTAMP
  4. Default: NOW()
  5. Click Create Column
2

Migrate the Database

Click Migrate DBRun Migration
3

Order Posts by Date

Edit Web/Controller/Posts.hs:
action PostsAction = do
    posts <- query @Post
        |> orderByDesc #createdAt
        |> fetch
    render IndexView { .. }
4

Display Relative Time

Edit Web/View/Posts/Show.hs:
<h1>{post.title}</h1>
<p>{post.createdAt |> timeAgo}</p>
<div>{post.body}</div>
This shows times like “5 minutes ago”.

7. Add Markdown Support

Install the mmark Package

Edit flake.nix and add mmark to haskellPackages:
flake.nix
haskellPackages = p: with p; [
    p.ihp
    cabal-install
    base
    wai
    text
    hlint
    mmark  # Add this line
];
Restart the dev server:
# Press CTRL+C, then:
devenv up

Implement Markdown Rendering

Edit Web/View/Posts/Show.hs:
Web/View/Posts/Show.hs
import qualified Text.MMark as MMark

instance View ShowView where
    html ShowView { .. } = [hsx|
        {breadcrumb}
        <h1>{post.title}</h1>
        <p>{post.createdAt |> timeAgo}</p>
        <div>{post.body |> renderMarkdown}</div>
    |]

renderMarkdown :: Text -> Html
renderMarkdown text =
    case text |> MMark.parse "" of
        Left error -> "Something went wrong"
        Right markdown -> MMark.render markdown |> tshow |> preEscapedToHtml

Update the Form

Edit both Web/View/Posts/Edit.hs and Web/View/Posts/New.hs:
renderForm :: Post -> Html
renderForm post = formFor post [hsx|
    {(textField #title)}
    {(textareaField #body) { helpText = "You can use Markdown here"} }
    {submitButton}
|]

Add Markdown Validation

Edit Web/Controller/Posts.hs:
Web/Controller/Posts.hs
import qualified Text.MMark as MMark

buildPost post = post
    |> fill @["title","body"]
    |> validateField #title nonEmpty
    |> validateField #body nonEmpty
    |> validateField #body isMarkdown

isMarkdown :: Text -> ValidatorResult
isMarkdown text =
    case MMark.parse "" text of
        Left _ -> Failure "Please provide valid Markdown"
        Right _ -> Success

8. Add Comments

Create the Comments Table

1

Add Comments Table

In the Schema Designer:
  1. Right-click → Add Table
  2. Name: comments
  3. Add columns:
    • post_id (UUID) - Check “References posts”
    • author (TEXT)
    • body (TEXT)
    • created_at (TIMESTAMP, default: NOW())
2

Configure Foreign Key

Click on “FOREIGN KEY: posts” next to post_id to configure the ON DELETE behavior.
3

Migrate

Click Migrate DBRun Migration

Generate Comments Controller

  1. Go to Code Generator
  2. Controller name: Comments
  3. Click PreviewGenerate
Edit Web/Types.hs to add postId parameter:
Web/Types.hs
data CommentsController
    = CommentsAction
    | NewCommentAction { postId :: !(Id Post) }  -- Add postId here
    | ShowCommentAction { commentId :: !(Id Comment) }
    | CreateCommentAction
    | EditCommentAction { commentId :: !(Id Comment) }
    | UpdateCommentAction { commentId :: !(Id Comment) }
    | DeleteCommentAction { commentId :: !(Id Comment) }
Edit Web/View/Posts/Show.hs:
<div>{post.body |> renderMarkdown}</div>
<a href={NewCommentAction post.id}>Add Comment</a>

Update the New Comment Action

Edit Web/Controller/Comments.hs:
Web/Controller/Comments.hs
action NewCommentAction { postId } = do
    let comment = newRecord
            |> set #postId postId
    post <- fetch postId
    render NewView { .. }

action CreateCommentAction = do
    let comment = newRecord @Comment
    comment
        |> buildComment
        |> ifValid \case
            Left comment -> do
                post <- fetch comment.postId
                render NewView { .. }
            Right comment -> do
                comment <- comment |> createRecord
                setSuccessMessage "Comment created"
                redirectTo ShowPostAction { postId = comment.postId }

Update the Comment Form

Edit Web/View/Comments/New.hs:
Web/View/Comments/New.hs
data NewView = NewView
    { comment :: Comment
    , post :: Post
    }

instance View NewView where
    html NewView { .. } = [hsx|
        <h1>New Comment for <q>{post.title}</q></h1>
        {renderForm comment}
    |]

renderForm :: Comment -> Html
renderForm comment = formFor comment [hsx|
    {(hiddenField #postId)}
    {(textField #author)}
    {(textareaField #body)}
    {submitButton}
|]

Display Comments on Posts

Edit Web/View/Posts/Show.hs:
Web/View/Posts/Show.hs
data ShowView = ShowView { post :: Include "comments" Post }

instance View ShowView where
    html ShowView { .. } = [hsx|
        {breadcrumb}
        <h1>{post.title}</h1>
        <p>{post.createdAt |> timeAgo}</p>
        <div>{post.body |> renderMarkdown}</div>
        
        <div>{forEach post.comments renderComment}</div>
        <a href={NewCommentAction post.id}>Add Comment</a>
    |]

renderComment comment = [hsx|
    <div class="mt-4">
        <h5>{comment.author}</h5>
        <p>{comment.body}</p>
    </div>
|]
Edit Web/Controller/Posts.hs to fetch comments:
Web/Controller/Posts.hs
action ShowPostAction { postId } = do
    post <- fetch postId
        >>= pure . modify #comments (orderByDesc #createdAt)
        >>= fetchRelated #comments
    render ShowView { .. }
The Include "comments" type tells IHP to fetch the related comments. The fetchRelated #comments function executes the query.

Congratulations!

You’ve built a complete blog application with:
  • ✅ Posts with Markdown support
  • ✅ Comments with relationships
  • ✅ Form validation
  • ✅ Timestamps and ordering
  • ✅ Full CRUD operations

Next Steps

Star IHP on GitHub

Join the IHP community and contribute to the future of type-safe web development

Join IHP Slack

Get help with Haskell type errors and connect with other developers

Subscribe to Updates

Stay in the loop with IHP release emails

Explore the Guides

Learn about routing, views, controllers, forms, and more in The Basics section
Good to know: To delete an IHP project, just delete the project directory. All dependencies are managed by Nix, so there’s no system-wide cleanup needed.

Build docs developers (and LLMs) love