diff --git a/shard.yml b/shard.yml
index c19ca00..64132a9 100644
--- a/shard.yml
+++ b/shard.yml
@@ -29,4 +29,4 @@ dependencies:
 development_dependencies:
   lucky_flow:
     github: luckyframework/lucky_flow
-    version: ~> 0.7.3
\ No newline at end of file
+    version: ~> 0.7.3
diff --git a/spec/classes/iframe_media_resolver_spec.cr b/spec/classes/iframe_media_resolver_spec.cr
new file mode 100644
index 0000000..ed167a8
--- /dev/null
+++ b/spec/classes/iframe_media_resolver_spec.cr
@@ -0,0 +1,27 @@
+require "../spec_helper"
+
+include Nodes
+
+describe IFrameMediaResolver do
+  around_each do |example|
+    original_client = IFrameMediaResolver.http_client
+    IFrameMediaResolver.http_client = FakeMediumClient
+    example.run
+    IFrameMediaResolver.http_client = original_client
+  end
+
+  it "returns a url of the embedded page" do
+    iframe = PostResponse::IFrame.from_json <<-JSON
+      {
+        "mediaResource": {
+          "id": "d4515fff7ecd02786e75fc8997c94bbf"
+        }
+      }
+    JSON
+    resolver = IFrameMediaResolver.new(iframe: iframe)
+
+    result = resolver.fetch_href
+
+    result.should eq("https://example.com")
+  end
+end
diff --git a/spec/classes/markup_converter_spec.cr b/spec/classes/markup_converter_spec.cr
new file mode 100644
index 0000000..a87a2b2
--- /dev/null
+++ b/spec/classes/markup_converter_spec.cr
@@ -0,0 +1,99 @@
+require "../spec_helper"
+
+include Nodes
+
+describe MarkupConverter do
+  it "returns just text with no markups" do
+    json = <<-JSON
+      {
+        "text": "Hello, world",
+        "type": "P",
+        "markups": [],
+        "href": null,
+        "iframe": null,
+        "layout": null,
+        "metadata": null
+      }
+    JSON
+    paragraph = PostResponse::Paragraph.from_json(json)
+
+    result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups)
+
+    result.should eq([Text.new(content: "Hello, world")])
+  end
+
+  it "returns just text with multiple markups" do
+    json = <<-JSON
+      {
+        "text": "strong and emphasized only",
+        "type": "P",
+        "markups": [
+          {
+            "title": null,
+            "type": "STRONG",
+            "href": null,
+            "start": 0,
+            "end": 6,
+            "rel": null,
+            "anchorType": null
+          },
+          {
+            "title": null,
+            "type": "EM",
+            "href": null,
+            "start": 11,
+            "end": 21,
+            "rel": null,
+            "anchorType": null
+          }
+        ],
+        "href": null,
+        "iframe": null,
+        "layout": null,
+        "metadata": null
+      }
+    JSON
+    paragraph = PostResponse::Paragraph.from_json(json)
+
+    result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups)
+
+    result.should eq([
+      Strong.new(children: [Text.new(content: "strong")] of Child),
+      Text.new(content: " and "),
+      Emphasis.new(children: [Text.new(content: "emphasized")] of Child),
+      Text.new(content: " only"),
+    ])
+  end
+
+  it "returns just text with a code markup" do
+    json = <<-JSON
+      {
+        "text": "inline code",
+        "type": "P",
+        "markups": [
+          {
+            "title": null,
+            "type": "CODE",
+            "href": null,
+            "start": 7,
+            "end": 11,
+            "rel": null,
+            "anchorType": null
+          }
+        ],
+        "href": null,
+        "iframe": null,
+        "layout": null,
+        "metadata": null
+      }
+    JSON
+    paragraph = PostResponse::Paragraph.from_json(json)
+
+    result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups)
+
+    result.should eq([
+      Text.new(content: "inline "),
+      Code.new(children: [Text.new(content: "code")] of Child),
+    ])
+  end
+end
diff --git a/spec/classes/paragraph_converter_spec.cr b/spec/classes/paragraph_converter_spec.cr
new file mode 100644
index 0000000..14faef2
--- /dev/null
+++ b/spec/classes/paragraph_converter_spec.cr
@@ -0,0 +1,271 @@
+require "../spec_helper"
+
+include Nodes
+
+describe ParagraphConverter do
+  around_each do |example|
+    original_client = IFrameMediaResolver.http_client
+    IFrameMediaResolver.http_client = FakeMediumClient
+    example.run
+    IFrameMediaResolver.http_client = original_client
+  end
+
+  it "converts a simple structure with no markups" do
+    paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
+      [
+        {
+          "text": "Title",
+          "type": "H3",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        }
+      ]
+    JSON
+    expected = [Heading3.new(children: [Text.new(content: "Title")] of Child)]
+
+    result = ParagraphConverter.new.convert(paragraphs)
+
+    result.should eq expected
+  end
+
+  it "converts a simple structure with a markup" do
+    paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
+      [
+        {
+          "text": "inline code",
+          "type": "P",
+          "markups": [
+            {
+              "name": null,
+              "title": null,
+              "type": "CODE",
+              "href": null,
+              "start": 7,
+              "end": 11,
+              "rel": null,
+              "anchorType": null
+            }
+          ],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        }
+      ]
+    JSON
+    expected = [
+      Paragraph.new(children: [
+        Text.new(content: "inline "),
+        Code.new(children: [Text.new(content: "code")] of Child),
+      ] of Child)
+    ]
+
+    result = ParagraphConverter.new.convert(paragraphs)
+
+    result.should eq expected
+  end
+
+  it "groups <ul> list items into one list" do
+    paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
+      [
+        {
+          "text": "One",
+          "type": "ULI",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        },
+        {
+          "text": "Two",
+          "type": "ULI",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        },
+        {
+          "text": "Not a list item",
+          "type": "P",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        }
+      ]
+    JSON
+    expected = [
+      UnorderedList.new(children: [
+        ListItem.new(children: [Text.new(content: "One")] of Child),
+        ListItem.new(children: [Text.new(content: "Two")] of Child),
+      ] of Child),
+      Paragraph.new(children: [Text.new(content: "Not a list item")] of Child),
+    ]
+
+    result = ParagraphConverter.new.convert(paragraphs)
+
+    result.should eq expected
+  end
+
+  it "groups <ol> list items into one list" do
+    paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
+      [
+        {
+          "text": "One",
+          "type": "OLI",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        },
+        {
+          "text": "Two",
+          "type": "OLI",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        },
+        {
+          "text": "Not a list item",
+          "type": "P",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        }
+      ]
+    JSON
+    expected = [
+      OrderedList.new(children: [
+        ListItem.new(children: [Text.new(content: "One")] of Child),
+        ListItem.new(children: [Text.new(content: "Two")] of Child),
+      ] of Child),
+      Paragraph.new(children: [Text.new(content: "Not a list item")] of Child),
+    ]
+
+    result = ParagraphConverter.new.convert(paragraphs)
+
+    result.should eq expected
+  end
+
+  it "converts all the tags" do
+    paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
+      [
+        {
+          "text": "text",
+          "type": "H3",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        },
+        {
+          "text": "text",
+          "type": "H4",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        },
+        {
+          "text": "text",
+          "type": "P",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        },
+        {
+          "text": "text",
+          "type": "PRE",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        },
+        {
+          "text": "text",
+          "type": "BQ",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        },
+        {
+          "text": "text",
+          "type": "ULI",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        },
+        {
+          "text": "text",
+          "type": "OLI",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": null
+        },
+        {
+          "text": "text",
+          "type": "IMG",
+          "markups": [],
+          "href": null,
+          "iframe": null,
+          "layout": null,
+          "metadata": {
+            "id": "1*miroimage.png",
+            "originalWidth": 618,
+            "originalHeight": 682
+          }
+        },
+        {
+          "text": "",
+          "type": "IFRAME",
+          "markups": [],
+          "href": null,
+          "iframe": {
+            "mediaResource": {
+              "id": "7c6231d165bf9fc1853f259a7b55bd14"
+            }
+          },
+          "layout": null,
+          "metadata": null
+        }
+      ]
+    JSON
+    expected = [
+      Heading3.new([Text.new("text")] of Child),
+      Heading4.new([Text.new("text")] of Child),
+      Paragraph.new([Text.new("text")] of Child),
+      Preformatted.new([Text.new("text")] of Child),
+      BlockQuote.new([Text.new("text")] of Child),
+      UnorderedList.new([ListItem.new([Text.new("text")] of Child)] of Child),
+      OrderedList.new([ListItem.new([Text.new("text")] of Child)] of Child),
+      Image.new(src: "1*miroimage.png"),
+      IFrame.new(href: "https://example.com"),
+    ]
+
+    result = ParagraphConverter.new.convert(paragraphs)
+
+    result.should eq expected
+  end
+end
diff --git a/spec/components/page_content_spec.cr b/spec/components/page_content_spec.cr
new file mode 100644
index 0000000..3bab1c1
--- /dev/null
+++ b/spec/components/page_content_spec.cr
@@ -0,0 +1,189 @@
+require "../spec_helper"
+
+include Nodes
+
+describe PageContent do
+  it "renders a single parent/child node structure" do
+    page = Page.new(nodes: [
+      Paragraph.new(children: [
+        Text.new(content: "hi"),
+      ] of Child)
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<p>hi</p>)
+  end
+
+  it "renders multiple childrens" do
+    page = Page.new(nodes: [
+      Paragraph.new(children: [
+        Text.new(content: "Hello, "),
+        Emphasis.new(children: [
+          Text.new(content: "World!")
+        ] of Child)
+      ] of Child),
+      UnorderedList.new(children: [
+        ListItem.new(children: [
+          Text.new(content: "List!")
+        ] of Child),
+        ListItem.new(children: [
+          Text.new(content: "Again!"),
+        ] of Child)
+      ] of Child)
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<p>Hello, <em>World!</em></p><ul><li>List!</li><li>Again!</li></ul>)
+  end
+
+  it "renders a blockquote" do
+    page = Page.new(nodes: [
+      BlockQuote.new(children: [
+        Text.new("Wayne Gretzky. Michael Scott.")
+      ] of Child)
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<blockquote>Wayne Gretzky. Michael Scott.</blockquote>)
+  end
+
+  it "renders code" do
+    page = Page.new(nodes: [
+      Code.new(children: [
+        Text.new("foo = bar")
+      ] of Child)
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<code>foo = bar</code>)
+  end
+
+  it "renders empasis" do
+    page = Page.new(nodes: [
+      Paragraph.new(children: [
+        Text.new(content: "This is "),
+        Emphasis.new(children: [
+          Text.new(content: "neat!")
+        ] of Child),
+      ] of Child),
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<p>This is <em>neat!</em></p>)
+  end
+
+  it "renders an H3" do
+    page = Page.new(nodes: [
+      Heading3.new(children: [
+        Text.new(content: "Title!"),
+      ] of Child),
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<h3>Title!</h3>)
+  end
+
+  it "renders an H4" do
+    page = Page.new(nodes: [
+      Heading4.new(children: [
+        Text.new(content: "In Conclusion..."),
+      ] of Child),
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<h4>In Conclusion...</h4>)
+  end
+
+  it "renders an image" do
+    page = Page.new(nodes: [
+      Paragraph.new(children: [
+        Image.new(src: "image.png"),
+      ] of Child)
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<p><img src="https://cdn-images-1.medium.com/image.png"></p>)
+  end
+
+  it "renders an iframe container" do
+    page = Page.new(nodes: [
+      Paragraph.new(children: [
+        IFrame.new(href: "https://example.com"),
+      ] of Child)
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<p><div class="embedded"><a href="https://example.com">Click to visit embedded content</a></div></p>)
+  end
+
+  it "renders an ordered list" do
+    page = Page.new(nodes: [
+      OrderedList.new(children: [
+        ListItem.new(children: [Text.new("One")] of Child),
+        ListItem.new(children: [Text.new("Two")] of Child),
+      ] of Child),
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<ol><li>One</li><li>Two</li></ol>)
+  end
+
+  it "renders an preformatted text" do
+    page = Page.new(nodes: [
+      Paragraph.new(children: [
+        Text.new("Hello, world!"),
+      ] of Child),
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<p>Hello, world!</p>)
+  end
+
+  it "renders an preformatted text" do
+    page = Page.new(nodes: [
+      Preformatted.new(children: [
+        Text.new("New\nline"),
+      ] of Child),
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<pre>New\nline</pre>)
+  end
+
+  it "renders strong text" do
+    page = Page.new(nodes: [
+      Strong.new(children: [
+        Text.new("Oh yeah!"),
+      ] of Child),
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<strong>Oh yeah!</strong>)
+  end
+
+  it "renders an unordered list" do
+    page = Page.new(nodes: [
+      UnorderedList.new(children: [
+        ListItem.new(children: [Text.new("Apple")] of Child),
+        ListItem.new(children: [Text.new("Banana")] of Child),
+      ] of Child),
+    ] of Child)
+
+    html = PageContent.new(page: page).render_to_string
+
+    html.should eq %(<ul><li>Apple</li><li>Banana</li></ul>)
+  end
+end
diff --git a/spec/support/fake_medium_client.cr b/spec/support/fake_medium_client.cr
new file mode 100644
index 0000000..9ae65e2
--- /dev/null
+++ b/spec/support/fake_medium_client.cr
@@ -0,0 +1,9 @@
+class FakeMediumClient < MediumClient
+  def self.media_data(media_id : String) : MediaResponse::Root
+    MediaResponse::Root.from_json(
+      <<-JSON
+        {"payload": {"value": {"href": "https://example.com"}}}
+      JSON
+    )
+  end
+end
diff --git a/src/actions/articles/show.cr b/src/actions/articles/show.cr
index efa10d8..842e17f 100644
--- a/src/actions/articles/show.cr
+++ b/src/actions/articles/show.cr
@@ -1,12 +1,16 @@
 require "json"
 
 class Articles::Show < BrowserAction
-  get "/post/:post_id" do
+  get "/posts/:post_id" do
     if Lucky::Env.use_local?
       response = LocalClient.post_data(post_id)
     else
       response = MediumClient.post_data(post_id)
     end
-    html ShowPage, post_response: response
+    content = ParagraphConverter.new.convert(
+      response.data.post.content.bodyModel.paragraphs
+    )
+    page = Page.new(nodes: content)
+    html ShowPage, page: page
   end
 end
diff --git a/src/app.cr b/src/app.cr
index 1a2f4d1..b5f93a5 100644
--- a/src/app.cr
+++ b/src/app.cr
@@ -3,6 +3,7 @@ require "./shards"
 Lucky::AssetHelpers.load_manifest
 
 require "./app_database"
+require "./constants"
 require "./models/base_model"
 require "./models/mixins/**"
 require "./models/**"
@@ -16,6 +17,8 @@ require "./actions/mixins/**"
 require "./actions/**"
 require "./components/base_component"
 require "./components/**"
+require "./classes/**"
+require "./clients/**"
 require "./pages/**"
 require "../config/env"
 require "../config/**"
diff --git a/src/classes/iframe_media_resolver.cr b/src/classes/iframe_media_resolver.cr
new file mode 100644
index 0000000..06d6e61
--- /dev/null
+++ b/src/classes/iframe_media_resolver.cr
@@ -0,0 +1,13 @@
+class IFrameMediaResolver
+  class_property http_client : MediumClient.class = MediumClient
+
+  getter iframe
+
+  def initialize(@iframe : PostResponse::IFrame)
+  end
+
+  def fetch_href
+    response = @@http_client.media_data(iframe.mediaResource.id)
+    response.payload.value.href
+  end
+end
diff --git a/src/classes/markup_converter.cr b/src/classes/markup_converter.cr
new file mode 100644
index 0000000..0e40f53
--- /dev/null
+++ b/src/classes/markup_converter.cr
@@ -0,0 +1,74 @@
+struct StringSplit
+  getter pre, content, post
+
+  def initialize(@pre : String, @content : String, @post : String)
+  end
+end
+
+class MarkupConverter
+  include Nodes
+
+  getter markups : Array(PostResponse::Markup)
+  getter text : String
+
+  def self.convert(text : String, markups : Array(PostResponse::Markup))
+    new(text, markups).convert
+  end
+
+  def initialize(@text : String, @markups : Array(PostResponse::Markup))
+  end
+
+  def convert : Array(Child)
+    if markups.empty?
+      return [Text.new(content: text)] of Child
+    end
+    offset = 0
+    text_splits = markups.reduce([text]) do |splits, markup|
+      individual_split = split_string(markup.start - offset, markup.end - offset, splits.pop)
+      offset = markup.end
+      splits.push(individual_split.pre)
+      splits.push(individual_split.content)
+      splits.push(individual_split.post)
+    end
+    text_splits.in_groups_of(2, "").map_with_index do |split, index|
+      plain, to_be_marked = split
+      markup = markups.fetch(index, Text.new(""))
+      if markup.is_a?(Text)
+        [Text.new(plain)] of Child
+      else
+        case markup.type
+        when PostResponse::MarkupType::CODE
+          container = construct_markup(to_be_marked, Code)
+        when PostResponse::MarkupType::EM
+          container = construct_markup(to_be_marked, Emphasis)
+        when PostResponse::MarkupType::STRONG
+          container = construct_markup(to_be_marked, Strong)
+        else
+          container = construct_markup(to_be_marked, Code)
+        end
+        [Text.new(plain), container] of Child
+      end
+    end.flatten.reject(&.empty?)
+  end
+
+  private def construct_markup(text : String, container : Container.class) : Child
+    container.new(children: [Text.new(content: text)] of Child)
+  end
+
+  private def split_string(start : Int32, finish : Int32, string : String)
+    if start.zero?
+      pre = ""
+    else
+      pre = string[0...start]
+    end
+
+    if finish == string.size
+      post = ""
+    else
+      post = string[finish...string.size]
+    end
+
+    content = string[start...finish]
+    StringSplit.new(pre, content, post)
+  end
+end
diff --git a/src/classes/paragraph_converter.cr b/src/classes/paragraph_converter.cr
new file mode 100644
index 0000000..1288a04
--- /dev/null
+++ b/src/classes/paragraph_converter.cr
@@ -0,0 +1,79 @@
+class ParagraphConverter
+  include Nodes
+
+  def convert(paragraphs : Array(PostResponse::Paragraph)) : Array(Child)
+    if paragraphs.first?.nil?
+      return [Empty.new] of Child
+    else
+      case paragraphs.first.type
+      when PostResponse::ParagraphType::BQ
+        paragraph = paragraphs.shift
+        children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+        node = BlockQuote.new(children: children)
+      when PostResponse::ParagraphType::H3
+        paragraph = paragraphs.shift
+        children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+        node = Heading3.new(children: children)
+      when PostResponse::ParagraphType::H4
+        paragraph = paragraphs.shift
+        children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+        node = Heading4.new(children: children)
+      when PostResponse::ParagraphType::IFRAME
+        paragraph = paragraphs.shift
+        if iframe = paragraph.iframe
+          resolver = IFrameMediaResolver.new(iframe: iframe)
+          href = resolver.fetch_href
+          node = IFrame.new(href: href)
+        else
+          node = Empty.new
+        end
+      when PostResponse::ParagraphType::IMG
+        paragraph = paragraphs.shift
+        if metadata = paragraph.metadata
+          node = Image.new(src: metadata.id)
+        else
+          node = Empty.new
+        end
+      when PostResponse::ParagraphType::OLI
+        list_items = convert_oli(paragraphs)
+        node = OrderedList.new(children: list_items)
+      when PostResponse::ParagraphType::P
+        paragraph = paragraphs.shift
+        children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+        node = Paragraph.new(children: children)
+      when PostResponse::ParagraphType::PRE
+        paragraph = paragraphs.shift
+        children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+        node = Preformatted.new(children: children)
+      when PostResponse::ParagraphType::ULI
+        list_items = convert_uli(paragraphs)
+        node = UnorderedList.new(children: list_items)
+      else
+        paragraphs.shift # so we don't recurse infinitely
+        node = Empty.new
+      end
+
+      [node, convert(paragraphs)].flatten.reject(&.empty?)
+    end
+  end
+
+  private def convert_uli(paragraphs : Array(PostResponse::Paragraph)) : Array(Child)
+    if paragraphs.first? && paragraphs.first.type.is_a?(PostResponse::ParagraphType::ULI)
+      paragraph = paragraphs.shift
+      children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+      [ListItem.new(children: children)] + convert_uli(paragraphs)
+    else
+      [] of Child
+    end
+  end
+
+  private def convert_oli(paragraphs : Array(PostResponse::Paragraph)) : Array(Child)
+    if paragraphs.first? && paragraphs.first.type.is_a?(PostResponse::ParagraphType::OLI)
+      paragraph = paragraphs.shift
+      children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+      [ListItem.new(children: children)] + convert_oli(paragraphs)
+    else
+      [] of Child
+    end
+  end
+end
diff --git a/src/actions/clients/local_client.cr b/src/clients/local_client.cr
similarity index 100%
rename from src/actions/clients/local_client.cr
rename to src/clients/local_client.cr
diff --git a/src/actions/clients/medium_client.cr b/src/clients/medium_client.cr
similarity index 94%
rename from src/actions/clients/medium_client.cr
rename to src/clients/medium_client.cr
index 4d140e1..6a2328d 100644
--- a/src/actions/clients/medium_client.cr
+++ b/src/clients/medium_client.cr
@@ -1,9 +1,6 @@
 require "json"
 
 class MediumClient
-  # https://stackoverflow.com/questions/2669690/
-  JSON_HIJACK_STRING = "])}while(1);</x>"
-
   def self.post_data(post_id : String) : PostResponse::Root
     client = HTTP::Client.new("medium.com", tls: true)
     response = client.post("/_/graphql", headers: headers, body: body(post_id))
diff --git a/src/components/page_content.cr b/src/components/page_content.cr
new file mode 100644
index 0000000..49fe832
--- /dev/null
+++ b/src/components/page_content.cr
@@ -0,0 +1,84 @@
+class PageContent < BaseComponent
+  include Nodes
+  needs page : Page
+
+  def render
+    page.nodes.each do |node|
+      render_child(node)
+    end
+  end
+
+  def render_children(children : Children)
+    children.each { |child| render_child(child) }
+  end
+
+  def render_child(node : BlockQuote)
+    blockquote { render_children(node.children) }
+  end
+
+  def render_child(node : Code)
+    code { render_children(node.children) }
+  end
+
+  def render_child(container : Container)
+    # Should never get called
+    raw "<!-- a Container was rendered -->"
+  end
+
+  def render_child(node : Emphasis)
+    em { render_children(node.children) }
+  end
+
+  def render_child(container : Empty)
+    # Should never get called
+    raw "<!-- an Empty was rendered -->"
+  end
+
+  def render_child(node : Heading3)
+    h3 { render_children(node.children) }
+  end
+
+  def render_child(node : Heading4)
+    h4 { render_children(node.children) }
+  end
+
+  def render_child(child : IFrame)
+    div class: "embedded" do
+      a href: child.href do
+        text "Click to visit embedded content"
+      end
+    end
+  end
+
+  def render_child(child : Image)
+    img src: child.src
+  end
+
+  def render_child(node : ListItem)
+    li { render_children(node.children) }
+  end
+
+  def render_child(node : OrderedList)
+    ol { render_children(node.children) }
+  end
+
+  def render_child(node : Paragraph)
+    para { render_children(node.children) }
+  end
+
+  def render_child(node : Preformatted)
+    pre { render_children(node.children) }
+  end
+
+  def render_child(node : Strong)
+    strong { render_children(node.children) }
+  end
+
+  def render_child(child : Text)
+    text child.content
+  end
+
+  def render_child(node : UnorderedList)
+    ul { render_children(node.children) }
+  end
+end
diff --git a/src/components/post.cr b/src/components/post.cr
deleted file mode 100644
index 587d463..0000000
--- a/src/components/post.cr
+++ /dev/null
@@ -1,29 +0,0 @@
-class Post::Post < BaseComponent
-  needs response : PostResponse::Root
-
-  def render
-    data = response.data.post.content.bodyModel.paragraphs
-    data.each do |paragraph|
-      case paragraph.type
-      when PostResponse::ParagraphType::H3
-        h3 paragraph.text
-      when PostResponse::ParagraphType::H4
-        h4 paragraph.text
-      when PostResponse::ParagraphType::P
-        para paragraph.text
-      when PostResponse::ParagraphType::PRE
-        pre paragraph.text
-      when PostResponse::ParagraphType::BQ
-        blockquote paragraph.text
-      when PostResponse::ParagraphType::OLI
-        li paragraph.text
-      when PostResponse::ParagraphType::ULI
-        li paragraph.text
-      when PostResponse::ParagraphType::IFRAME
-        mount IFrame, paragraph: paragraph
-      else
-        para "#{paragraph.type} not yet implimented"
-      end
-    end
-  end
-end
diff --git a/src/constants.cr b/src/constants.cr
new file mode 100644
index 0000000..a136d90
--- /dev/null
+++ b/src/constants.cr
@@ -0,0 +1,2 @@
+# https://stackoverflow.com/questions/2669690/
+JSON_HIJACK_STRING = "])}while(1);</x>"
diff --git a/src/models/media_response.cr b/src/models/media_response.cr
index 8ee915d..16d6749 100644
--- a/src/models/media_response.cr
+++ b/src/models/media_response.cr
@@ -13,6 +13,5 @@ class MediaResponse
 
   class Value < Base
     property href : String
-    property iframeSrc : String
   end
 end
diff --git a/src/models/nodes.cr b/src/models/nodes.cr
new file mode 100644
index 0000000..99d02a1
--- /dev/null
+++ b/src/models/nodes.cr
@@ -0,0 +1,107 @@
+module Nodes
+  alias Leaf = Text | Image | IFrame
+  alias Child = Container | Leaf | Empty
+  alias Children = Array(Child)
+
+  class Container
+    getter children : Children
+
+    def initialize(@children : Children)
+    end
+
+    def ==(other : Container)
+      other.children == children
+    end
+
+    def empty?
+      children.empty? || children.each(&.empty?)
+    end
+  end
+
+  class Empty
+    def empty?
+      true
+    end
+  end
+
+  class BlockQuote < Container
+  end
+
+  class Code < Container
+  end
+
+  class Emphasis < Container
+  end
+
+  class Heading3 < Container
+  end
+
+  class Heading4 < Container
+  end
+
+  class ListItem < Container
+  end
+
+  class OrderedList < Container
+  end
+
+  class Paragraph < Container
+  end
+
+  class Preformatted < Container
+  end
+
+  class Strong < Container
+  end
+
+  class UnorderedList < Container
+  end
+
+  class Text
+    getter content : String
+
+    def initialize(@content : String)
+    end
+
+    def ==(other : Text)
+      other.content == content
+    end
+
+    def empty?
+      content.empty?
+    end
+  end
+
+  class Image
+    IMAGE_HOST = "https://cdn-images-1.medium.com"
+
+    getter src : String
+
+    def initialize(src : String)
+      @src = "#{IMAGE_HOST}/#{src}"
+    end
+
+    def ==(other : Image)
+      other.src == src
+    end
+
+    def empty?
+      false
+    end
+  end
+
+  class IFrame
+    getter href : String
+
+    def initialize(@href : String)
+    end
+
+    def ==(other : IFrame)
+      other.href == href
+    end
+
+    def empty?
+      false
+    end
+  end
+end
diff --git a/src/models/page.cr b/src/models/page.cr
new file mode 100644
index 0000000..06e103d
--- /dev/null
+++ b/src/models/page.cr
@@ -0,0 +1,6 @@
+class Page
+  getter nodes : Nodes::Children
+
+  def initialize(@nodes : Nodes::Children)
+  end
+end
diff --git a/src/models/post_response.cr b/src/models/post_response.cr
index 93c92f5..c8447cc 100644
--- a/src/models/post_response.cr
+++ b/src/models/post_response.cr
@@ -33,20 +33,43 @@ class PostResponse
   class Paragraph < Base
     property text : String
     property type : ParagraphType
+    property markups : Array(Markup)
     property iframe : IFrame?
     property layout : String?
+    property metadata : Metadata?
   end
 
   enum ParagraphType
+    BQ
     H3
     H4
-    P
-    PRE
-    BQ
-    ULI
-    OLI
     IFRAME
     IMG
+    OLI
+    P
+    PRE
+    ULI
+  end
+
+  class Markup < Base
+    property title : String?
+    property type : MarkupType
+    property href : String?
+    property start : Int32
+    property end : Int32
+    property anchorType : AnchorType?
+  end
+
+  enum MarkupType
+    A
+    CODE
+    EM
+    STRONG
+  end
+
+  enum AnchorType
+    LINK
+    USER
   end
 
   class IFrame < Base
@@ -58,5 +81,8 @@ class PostResponse
   end
 
   class Metadata < Base
+    property id : String
+    property originalWidth : Int32
+    property originalHeight : Int32
   end
 end
diff --git a/src/pages/articles/show_page.cr b/src/pages/articles/show_page.cr
index d6e78bb..c5c2b0e 100644
--- a/src/pages/articles/show_page.cr
+++ b/src/pages/articles/show_page.cr
@@ -1,7 +1,7 @@
 class Articles::ShowPage < MainLayout
-  needs post_response : PostResponse::Root
+  needs page : Page
 
   def content
-    mount Post::Post, response: post_response
+    mount PageContent, page: page
   end
 end