diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml
index 5e7f4cd82..83630bc01 100644
--- a/.github/workflows/elixir.yml
+++ b/.github/workflows/elixir.yml
@@ -11,20 +11,28 @@ permissions:
 
 jobs:
   test:
-    name: test (OTP ${{matrix.otp}} | Elixir ${{matrix.elixir}})
-
-    env:
-      MIX_ENV: test
-      MDEX_BUILD: 1
+    name: "test: OTP ${{matrix.otp}} | Elixir ${{matrix.elixir}} | Phoenix ${{matrix.phoenix-version}} | LiveView ${{matrix.phoenix-live-view-version}}"
 
     strategy:
       matrix:
         include:
-          - elixir: "1.13.0"
-            otp: "23"
+          # minimum required versions
+          - otp: "23"
+            elixir: "1.13.0"
+            phoenix-version: "1.7.0"
+            phoenix-live-view-version: "0.19.0"
+
+          # latest
+          - otp: "26"
+            elixir: "1.15"
+            phoenix-version: "~> 1.7"
+            phoenix-live-view-version: "~> 0.20"
 
-          - elixir: "1.15.1"
-            otp: "26"
+    env:
+      MIX_ENV: test
+      MDEX_BUILD: 1
+      PHOENIX_VERSION: ${{matrix.phoenix-version}}
+      PHOENIX_LIVE_VIEW_VERSION: ${{matrix.phoenix-live-view-version}}
 
     runs-on: ubuntu-20.04
 
@@ -32,6 +40,7 @@ jobs:
       postgres:
         image: postgres:13.1
         env:
+          POSTGRES_USER: postgres
           POSTGRES_PASSWORD: postgres
         options: >-
           --health-cmd pg_isready
@@ -48,8 +57,8 @@ jobs:
       - name: Set up Elixir
         uses: erlef/setup-beam@v1
         with:
-          elixir-version: ${{ matrix.elixir }}
           otp-version: ${{ matrix.otp }}
+          elixir-version: ${{ matrix.elixir }}
 
       - name: Cache mix deps
         uses: actions/cache@v3
@@ -58,29 +67,31 @@ jobs:
           path: |
             deps
             _build
-          key: mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}
+          key: mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ matrix.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles('**/mix.lock') }}
           restore-keys: |
-            mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-
+            mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ matrix.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles('**/mix.lock') }}
 
       - run: mix do deps.get, deps.compile
-        if: steps.cache-deps.outputs.cache-hit != 'true'
 
       - run: mix tailwind.install
 
       - run: mix test
 
   quality:
-    name: quality (OTP ${{matrix.otp}} | Elixir ${{matrix.elixir}})
-
-    env:
-      MIX_ENV: dev
-      MDEX_BUILD: 1
+    name: "quality: OTP ${{matrix.otp}} | Elixir ${{matrix.elixir}} | Phoenix ${{matrix.phoenix-version}} | LiveView ${{matrix.phoenix-live-view-version}}"
 
     strategy:
       matrix:
         include:
-          - elixir: "1.15.1"
-            otp: "26"
+          # latest
+          - otp: "26"
+            elixir: "1.15"
+            phoenix-version: "~> 1.7"
+            phoenix-live-view-version: "~> 0.20"
+
+    env:
+      MIX_ENV: dev
+      MDEX_BUILD: 1
 
     runs-on: ubuntu-20.04
 
@@ -91,8 +102,8 @@ jobs:
       - name: Set up Elixir
         uses: erlef/setup-beam@v1
         with:
-          elixir-version: ${{ matrix.elixir }}
           otp-version: ${{ matrix.otp }}
+          elixir-version: ${{ matrix.elixir }}
 
       - name: Cache mix deps
         uses: actions/cache@v3
@@ -101,21 +112,20 @@ jobs:
           path: |
             deps
             _build
-          key: mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}
+          key: mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ matrix.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles('**/mix.lock') }}
           restore-keys: |
-            mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-
+            mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ matrix.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles('**/mix.lock') }}
 
       - run: mix do deps.get, deps.compile
-        if: steps.cache-deps.outputs.cache-hit != 'true'
 
       - name: Cache dialyzer
         uses: actions/cache@v2
         id: cache-plt
         with:
           path: priv/plts
-          key: dialyzer-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}
+          key: dialyzer-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ matrix.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles('**/mix.lock') }}
           restore-keys: |
-            dialyzer-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-
+            dialyzer-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ matrix.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles('**/mix.lock') }}
 
       - name: Generate dialyzer plt
         run: mix dialyzer --plt
@@ -123,7 +133,7 @@ jobs:
 
       - run: mix tailwind.install
 
-      # - run: mix compile --warnings-as-errors
+      - run: mix compile --warnings-as-errors
 
       - run: mix format --check-formatted
 
@@ -131,4 +141,4 @@ jobs:
 
       - run: mix credo --strict
 
-      - run: mix dialyzer
+      - run: mix dialyzer --format github
diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex
index cb5120cbd..9d2eb10b9 100644
--- a/lib/beacon/content.ex
+++ b/lib/beacon/content.ex
@@ -1770,6 +1770,27 @@ defmodule Beacon.Content do
     |> Repo.all()
   end
 
+  @doc type: :error_pages
+  @spec list_error_pages_by(Site.t(), keyword(), keyword()) :: Layout.t() | nil
+  def list_error_pages_by(site, clauses, opts \\ []) when is_atom(site) and is_list(clauses) do
+    per_page = Keyword.get(opts, :per_page, 20)
+    preloads = Keyword.get(opts, :preloads, [])
+
+    filter_layout_id =
+      if layout_id = clauses[:layout_id] do
+        dynamic([ep], ep.layout_id == ^layout_id)
+      else
+        true
+      end
+
+    site
+    |> query_list_error_pages_base()
+    |> query_list_error_pages_limit(per_page)
+    |> query_list_error_pages_preloads(preloads)
+    |> where(^filter_layout_id)
+    |> Repo.all()
+  end
+
   defp query_list_error_pages_base(site) do
     from p in ErrorPage,
       where: p.site == ^site,
diff --git a/lib/beacon/content/page.ex b/lib/beacon/content/page.ex
index 3bba23791..9ec8f7b63 100644
--- a/lib/beacon/content/page.ex
+++ b/lib/beacon/content/page.ex
@@ -36,7 +36,7 @@ defmodule Beacon.Content.Page do
     field :description, :string
     field :template, :string
     field :meta_tags, {:array, :map}, default: []
-    field :raw_schema, {:array, :map}, default: []
+    field :raw_schema, Beacon.Types.JsonArrayMap, default: []
     field :order, :integer, default: 1
     field :format, Beacon.Types.Atom, default: :heex
     field :extra, :map, default: %{}
@@ -112,6 +112,7 @@ defmodule Beacon.Content.Page do
       :title,
       :description,
       :meta_tags,
+      :raw_schema,
       :format
     ])
     |> cast(attrs, [:path], empty_values: [])
@@ -122,7 +123,6 @@ defmodule Beacon.Content.Page do
       :format
     ])
     |> validate_string([:path])
-    |> validate_raw_schema(attrs["raw_schema"])
     |> remove_all_newlines([:description])
     |> remove_empty_meta_attributes(:meta_tags)
     |> Content.PageField.apply_changesets(page.site, extra_attrs)
@@ -173,13 +173,4 @@ defmodule Beacon.Content.Page do
     |> Enum.reject(fn {_key, value} -> is_nil(value) || String.trim(value) == "" end)
     |> Map.new()
   end
-
-  defp validate_raw_schema(changeset, raw_schema) do
-    raw_schema = if raw_schema in ["", nil], do: "[]", else: raw_schema
-
-    case Jason.decode(raw_schema) do
-      {:ok, raw_schema} -> put_change(changeset, :raw_schema, raw_schema)
-      {:error, _} -> add_error(changeset, :raw_schema, "invalid schema")
-    end
-  end
 end
diff --git a/lib/beacon/loader.ex b/lib/beacon/loader.ex
index 008f47a0d..aa8469f5c 100644
--- a/lib/beacon/loader.ex
+++ b/lib/beacon/loader.ex
@@ -171,7 +171,7 @@ defmodule Beacon.Loader do
   end
 
   @doc false
-  def reload_module!(module, ast, file \\ "nofile") do
+  def reload_module!(module, ast, file \\ "nofile", failure_count \\ 0) do
     :code.delete(module)
     :code.purge(module)
     [{^module, _}] = Code.compile_quoted(ast, file)
@@ -179,16 +179,24 @@ defmodule Beacon.Loader do
     :ok
   rescue
     e ->
-      message = """
-      failed to load module #{inspect(module)}
+      if failure_count >= 3 do
+        Logger.debug("failed to load module #{inspect(module)} after #{failure_count} tries.")
 
-      Got:
+        message = """
+        failed to load module #{inspect(module)}
 
-        #{Exception.message(e)}"],
+        Got:
 
-      """
+          #{Exception.message(e)}
+
+        """
 
-      reraise Beacon.LoaderError, [message: message], __STACKTRACE__
+        reraise Beacon.LoaderError, [message: message], __STACKTRACE__
+      else
+        Logger.debug("failed to load module #{inspect(module)}, retrying...")
+        :timer.sleep(100 * (failure_count * 2))
+        reload_module!(module, ast, file, failure_count + 1)
+      end
   end
 
   # too slow to run the css compiler on every test
@@ -307,14 +315,12 @@ defmodule Beacon.Loader do
     e in UndefinedFunctionError ->
       case {failure_count, e} do
         {x, _} when x >= 10 ->
-          Logger.debug("failed to call #{inspect(module)} #{inspect(function)} 10 times.")
+          Logger.debug("failed to call #{inspect(module)} #{inspect(function)} after #{failure_count} tries.")
           reraise e, __STACKTRACE__
 
         {_, %UndefinedFunctionError{function: ^function, module: ^module}} ->
-          Logger.debug("failed to call #{inspect(module)} #{inspect(function)} with #{inspect(args)} for the #{failure_count + 1} time. Retrying.")
-
+          Logger.debug("failed to call #{inspect(module)} #{inspect(function)} with #{inspect(args)} for the #{failure_count + 1} time, retrying...")
           :timer.sleep(100 * (failure_count * 2))
-
           call_function_with_retry(module, function, args, failure_count + 1)
 
         _ ->
@@ -392,7 +398,7 @@ defmodule Beacon.Loader do
          :ok <- load_snippet_helpers(site),
          :ok <- load_stylesheets(site),
          {:ok, _module, _ast} <- Beacon.Loader.LayoutModuleLoader.load_layout!(layout),
-         :ok <- load_error_pages(site) do
+         :ok <- maybe_reload_error_pages(layout) do
       :ok
     else
       _ -> raise Beacon.LoaderError, message: "failed to load resources for layout #{layout.title} of site #{layout.site}"
@@ -480,6 +486,13 @@ defmodule Beacon.Loader do
     end
   end
 
+  # we need to reload error pages bacause the layout is embeeded into those pages
+  defp maybe_reload_error_pages(layout) do
+    error_pages = Content.list_error_pages_by(layout.site, [layout_id: layout.id], per_page: :infinity, preloads: [:layout])
+    ErrorPageModuleLoader.load_error_pages!(error_pages, layout.site)
+    :ok
+  end
+
   @doc false
   # https://github.com/phoenixframework/phoenix_live_view/blob/8fedc6927fd937fe381553715e723754b3596a97/lib/phoenix_live_view/channel.ex#L435-L437
   def exported?(m, f, a) do
diff --git a/lib/beacon/loader/error_page_module_loader.ex b/lib/beacon/loader/error_page_module_loader.ex
index 009d4cde4..4f224c8f0 100644
--- a/lib/beacon/loader/error_page_module_loader.ex
+++ b/lib/beacon/loader/error_page_module_loader.ex
@@ -4,6 +4,8 @@ defmodule Beacon.Loader.ErrorPageModuleLoader do
   alias Beacon.Content.ErrorPage
   alias Beacon.Loader
 
+  def load_error_pages!([] = _error_pages, _site), do: :skip
+
   def load_error_pages!(error_pages, site) do
     error_module = Loader.error_module_for_site(site)
     layout_functions = Enum.map(error_pages, &build_layout_fn/1)
diff --git a/lib/beacon/pub_sub.ex b/lib/beacon/pub_sub.ex
index cb207f7ab..a201935fd 100644
--- a/lib/beacon/pub_sub.ex
+++ b/lib/beacon/pub_sub.ex
@@ -4,7 +4,6 @@ defmodule Beacon.PubSub do
   require Logger
   alias Beacon.Content.Component
   alias Beacon.Content.ErrorPage
-  alias Beacon.Content.ErrorPage
   alias Beacon.Content.Layout
   alias Beacon.Content.LiveData
   alias Beacon.Content.Page
diff --git a/lib/beacon/template/heex/json_encoder.ex b/lib/beacon/template/heex/json_encoder.ex
index 893597010..ef6a1abf8 100644
--- a/lib/beacon/template/heex/json_encoder.ex
+++ b/lib/beacon/template/heex/json_encoder.ex
@@ -78,7 +78,13 @@ defmodule Beacon.Template.HEEx.JSONEncoder do
 
   """
   @spec encode(Beacon.Types.Site.t(), String.t(), map()) :: {:ok, [token()]} | {:error, String.t()}
-  def encode(site, template, assigns \\ %{}) when is_atom(site) and is_binary(template) and is_map(assigns) do
+  def encode(site, template, assigns \\ %{})
+
+  def encode(site, nil = _template, assigns) when is_atom(site) and is_map(assigns) do
+    encode(site, "", assigns)
+  end
+
+  def encode(site, template, assigns) when is_atom(site) and is_binary(template) and is_map(assigns) do
     case Beacon.Template.HEEx.Tokenizer.tokenize(template) do
       {:ok, tokens} -> {:ok, encode_tokens(tokens, site, assigns)}
       error -> error
diff --git a/lib/beacon/template/heex/lv_tokenizer.ex b/lib/beacon/template/heex/lv_tokenizer.ex
new file mode 100644
index 000000000..b2ebaf93a
--- /dev/null
+++ b/lib/beacon/template/heex/lv_tokenizer.ex
@@ -0,0 +1,717 @@
+# DO NOT CHANGE THIS FILE
+# It's a copy from https://github.com/phoenixframework/phoenix_live_view/blob/fb111738d56745f37338867b9faea86eb9baa6e1/lib/phoenix_live_view/tokenizer.ex
+
+defmodule Beacon.Template.HEEx.LVTokenizer do
+  @moduledoc false
+  @space_chars ~c"\s\t\f"
+  @quote_chars ~c"\"'"
+  @stop_chars ~c">/=\r\n" ++ @quote_chars ++ @space_chars
+
+  defmodule ParseError do
+    @moduledoc false
+    defexception [:file, :line, :column, :description]
+
+    @impl true
+    def message(exception) do
+      location =
+        exception.file
+        |> Path.relative_to_cwd()
+        |> Exception.format_file_line_column(exception.line, exception.column)
+
+      "#{location} #{exception.description}"
+    end
+
+    def code_snippet(source, meta, indentation \\ 0) do
+      line_start = max(meta.line - 3, 1)
+      line_end = meta.line
+      digits = line_end |> Integer.to_string() |> byte_size()
+      number_padding = String.duplicate(" ", digits)
+      indentation = String.duplicate(" ", indentation)
+
+      source
+      |> String.split(["\r\n", "\n"])
+      |> Enum.slice((line_start - 1)..(line_end - 1))
+      |> Enum.map_reduce(line_start, fn
+        expr, line_number when line_number == line_end ->
+          arrow = String.duplicate(" ", meta.column - 1) <> "^"
+          acc = "#{line_number} | #{indentation}#{expr}\n #{number_padding}| #{arrow}"
+          {acc, line_number + 1}
+
+        expr, line_number ->
+          line_number_padding = String.pad_leading("#{line_number}", digits)
+          {"#{line_number_padding} | #{indentation}#{expr}", line_number + 1}
+      end)
+      |> case do
+        {[], _} ->
+          ""
+
+        {snippet, _} ->
+          Enum.join(["\n #{number_padding}|" | snippet], "\n")
+      end
+    end
+  end
+
+  def finalize(_tokens, file, {:comment, line, column}, source) do
+    message = "expected closing `-->` for comment"
+    meta = %{line: line, column: column}
+    raise_syntax_error!(message, meta, %{source: source, file: file, indentation: 0})
+  end
+
+  def finalize(tokens, _file, _cont, _source) do
+    tokens
+    |> strip_text_token_fully()
+    |> Enum.reverse()
+    |> strip_text_token_fully()
+  end
+
+  @doc """
+  Initiate the Tokenizer state.
+
+  ### Params
+
+  * `indentation` - An integer that indicates the current indentation.
+  * `file` - Can be either a file or a string "nofile".
+  * `source` - The contents of the file as binary used to be tokenized.
+  * `tag_handler` - Tag handler to classify the tags. See `Phoenex.LiveView.TagHandler`
+    behavivour.
+  """
+  def init(indentation, file, source, tag_handler) do
+    %{
+      file: file,
+      column_offset: indentation + 1,
+      braces: [],
+      context: [],
+      source: source,
+      indentation: indentation,
+      tag_handler: tag_handler
+    }
+  end
+
+  @doc """
+  Tokenize the given text according to the given params.
+
+  ### Params
+
+  * `text` - The content to be tokenized.
+  * `meta` - A keyword list with `:line` and `:column`. Both must be integers.
+  * `tokens` - A list of tokens.
+  * `cont` - An atom that is `:text`, `:style`, or `:script`, or a tuple
+    {:comment, line, column}.
+  * `state` - The tokenizer state that must be initiated by `Tokenizer.init/3`
+
+  ### Examples
+
+      iex> alias Phoenix.LiveView.Tokenizer
+      iex> state = Tokenizer.init(text: "<section><div/></section>", cont: :text)
+      iex> Tokenizer.tokenize(state)
+      {[
+         {:close, :tag, "section", %{column: 16, line: 1}},
+         {:tag, "div", [], %{column: 10, line: 1, self_close: true}},
+         {:tag, "section", [], %{column: 1, line: 1}}
+       ], :text}
+  """
+  def tokenize(text, meta, tokens, cont, state) do
+    line = Keyword.get(meta, :line, 1)
+    column = Keyword.get(meta, :column, 1)
+
+    case cont do
+      :text -> handle_text(text, line, column, [], tokens, state)
+      :style -> handle_style(text, line, column, [], tokens, state)
+      :script -> handle_script(text, line, column, [], tokens, state)
+      {:comment, _, _} -> handle_comment(text, line, column, [], tokens, state)
+    end
+  end
+
+  ## handle_text
+
+  defp handle_text("\r\n" <> rest, line, _column, buffer, acc, state) do
+    handle_text(rest, line + 1, state.column_offset, ["\r\n" | buffer], acc, state)
+  end
+
+  defp handle_text("\n" <> rest, line, _column, buffer, acc, state) do
+    handle_text(rest, line + 1, state.column_offset, ["\n" | buffer], acc, state)
+  end
+
+  defp handle_text("<!doctype" <> rest, line, column, buffer, acc, state) do
+    handle_doctype(rest, line, column + 9, ["<!doctype" | buffer], acc, state)
+  end
+
+  defp handle_text("<!DOCTYPE" <> rest, line, column, buffer, acc, state) do
+    handle_doctype(rest, line, column + 9, ["<!DOCTYPE" | buffer], acc, state)
+  end
+
+  defp handle_text("<!--" <> rest, line, column, buffer, acc, state) do
+    state = update_in(state.context, &[:comment_start | &1])
+    handle_comment(rest, line, column + 4, ["<!--" | buffer], acc, state)
+  end
+
+  defp handle_text("</" <> rest, line, column, buffer, acc, state) do
+    text_to_acc = text_to_acc(buffer, acc, line, column, state.context)
+    handle_tag_close(rest, line, column + 2, text_to_acc, %{state | context: []})
+  end
+
+  defp handle_text("<" <> rest, line, column, buffer, acc, state) do
+    text_to_acc = text_to_acc(buffer, acc, line, column, state.context)
+    handle_tag_open(rest, line, column + 1, text_to_acc, %{state | context: []})
+  end
+
+  defp handle_text(<<c::utf8, rest::binary>>, line, column, buffer, acc, state) do
+    handle_text(rest, line, column + 1, [char_or_bin(c) | buffer], acc, state)
+  end
+
+  defp handle_text(<<>>, line, column, buffer, acc, state) do
+    ok(text_to_acc(buffer, acc, line, column, state.context), :text)
+  end
+
+  ## handle_doctype
+
+  defp handle_doctype(<<?>, rest::binary>>, line, column, buffer, acc, state) do
+    handle_text(rest, line, column + 1, [?> | buffer], acc, state)
+  end
+
+  defp handle_doctype("\r\n" <> rest, line, _column, buffer, acc, state) do
+    handle_doctype(rest, line + 1, state.column_offset, ["\r\n" | buffer], acc, state)
+  end
+
+  defp handle_doctype("\n" <> rest, line, _column, buffer, acc, state) do
+    handle_doctype(rest, line + 1, state.column_offset, ["\n" | buffer], acc, state)
+  end
+
+  defp handle_doctype(<<c::utf8, rest::binary>>, line, column, buffer, acc, state) do
+    handle_doctype(rest, line, column + 1, [char_or_bin(c) | buffer], acc, state)
+  end
+
+  ## handle_script
+
+  defp handle_script("</script>" <> rest, line, column, buffer, acc, state) do
+    acc = [
+      {:close, :tag, "script", %{line: line, column: column, inner_location: {line, column}}}
+      | text_to_acc(buffer, acc, line, column, [])
+    ]
+
+    handle_text(rest, line, column + 9, [], acc, state)
+  end
+
+  defp handle_script("\r\n" <> rest, line, _column, buffer, acc, state) do
+    handle_script(rest, line + 1, state.column_offset, ["\r\n" | buffer], acc, state)
+  end
+
+  defp handle_script("\n" <> rest, line, _column, buffer, acc, state) do
+    handle_script(rest, line + 1, state.column_offset, ["\n" | buffer], acc, state)
+  end
+
+  defp handle_script(<<c::utf8, rest::binary>>, line, column, buffer, acc, state) do
+    handle_script(rest, line, column + 1, [char_or_bin(c) | buffer], acc, state)
+  end
+
+  defp handle_script(<<>>, line, column, buffer, acc, _state) do
+    ok(text_to_acc(buffer, acc, line, column, []), :script)
+  end
+
+  ## handle_style
+
+  defp handle_style("</style>" <> rest, line, column, buffer, acc, state) do
+    acc = [
+      {:close, :tag, "style", %{line: line, column: column, inner_location: {line, column}}}
+      | text_to_acc(buffer, acc, line, column, [])
+    ]
+
+    handle_text(rest, line, column + 9, [], acc, state)
+  end
+
+  defp handle_style("\r\n" <> rest, line, _column, buffer, acc, state) do
+    handle_style(rest, line + 1, state.column_offset, ["\r\n" | buffer], acc, state)
+  end
+
+  defp handle_style("\n" <> rest, line, _column, buffer, acc, state) do
+    handle_style(rest, line + 1, state.column_offset, ["\n" | buffer], acc, state)
+  end
+
+  defp handle_style(<<c::utf8, rest::binary>>, line, column, buffer, acc, state) do
+    handle_style(rest, line, column + 1, [char_or_bin(c) | buffer], acc, state)
+  end
+
+  defp handle_style(<<>>, line, column, buffer, acc, _state) do
+    ok(text_to_acc(buffer, acc, line, column, []), :style)
+  end
+
+  ## handle_comment
+
+  defp handle_comment(rest, line, column, buffer, acc, state) do
+    case handle_comment(rest, line, column, buffer, state) do
+      {:text, rest, line, column, buffer} ->
+        state = update_in(state.context, &[:comment_end | &1])
+        handle_text(rest, line, column, buffer, acc, state)
+
+      {:ok, line_end, column_end, buffer} ->
+        acc = text_to_acc(buffer, acc, line_end, column_end, state.context)
+        # We do column - 4 to point to the opening <!--
+        ok(acc, {:comment, line, column - 4})
+    end
+  end
+
+  defp handle_comment("\r\n" <> rest, line, _column, buffer, state) do
+    handle_comment(rest, line + 1, state.column_offset, ["\r\n" | buffer], state)
+  end
+
+  defp handle_comment("\n" <> rest, line, _column, buffer, state) do
+    handle_comment(rest, line + 1, state.column_offset, ["\n" | buffer], state)
+  end
+
+  defp handle_comment("-->" <> rest, line, column, buffer, _state) do
+    {:text, rest, line, column + 3, ["-->" | buffer]}
+  end
+
+  defp handle_comment(<<c::utf8, rest::binary>>, line, column, buffer, state) do
+    handle_comment(rest, line, column + 1, [char_or_bin(c) | buffer], state)
+  end
+
+  defp handle_comment(<<>>, line, column, buffer, _state) do
+    {:ok, line, column, buffer}
+  end
+
+  ## handle_tag_open
+
+  defp handle_tag_open(text, line, column, acc, state) do
+    case handle_tag_name(text, column, []) do
+      {:ok, name, new_column, rest} ->
+        meta = %{line: line, column: column - 1, inner_location: nil, tag_name: name}
+
+        case state.tag_handler.classify_type(name) do
+          {:error, message} ->
+            raise_syntax_error!(message, meta, state)
+
+          {type, name} ->
+            acc = [{type, name, [], meta} | acc]
+            handle_maybe_tag_open_end(rest, line, new_column, acc, state)
+        end
+
+      :error ->
+        message =
+          "expected tag name after <. If you meant to use < as part of a text, use &lt; instead"
+
+        meta = %{line: line, column: column}
+
+        raise_syntax_error!(message, meta, state)
+    end
+  end
+
+  ## handle_tag_close
+
+  defp handle_tag_close(text, line, column, acc, state) do
+    case handle_tag_name(text, column, []) do
+      {:ok, name, new_column, ">" <> rest} ->
+        meta = %{
+          line: line,
+          column: column - 2,
+          inner_location: {line, column - 2},
+          tag_name: name
+        }
+
+        case state.tag_handler.classify_type(name) do
+          {:error, message} ->
+            raise_syntax_error!(message, meta, state)
+
+          {type, name} ->
+            acc = [{:close, type, name, meta} | acc]
+            handle_text(rest, line, new_column + 1, [], acc, state)
+        end
+
+      {:ok, _, new_column, _} ->
+        message = "expected closing `>`"
+        meta = %{line: line, column: new_column}
+        raise_syntax_error!(message, meta, state)
+
+      :error ->
+        message = "expected tag name after </"
+        meta = %{line: line, column: column}
+        raise_syntax_error!(message, meta, state)
+    end
+  end
+
+  ## handle_tag_name
+
+  defp handle_tag_name(<<c::utf8, _rest::binary>> = text, column, buffer)
+       when c in @stop_chars do
+    done_tag_name(text, column, buffer)
+  end
+
+  defp handle_tag_name(<<c::utf8, rest::binary>>, column, buffer) do
+    handle_tag_name(rest, column + 1, [char_or_bin(c) | buffer])
+  end
+
+  defp handle_tag_name(<<>>, column, buffer) do
+    done_tag_name(<<>>, column, buffer)
+  end
+
+  defp done_tag_name(_text, _column, []) do
+    :error
+  end
+
+  defp done_tag_name(text, column, buffer) do
+    {:ok, buffer_to_string(buffer), column, text}
+  end
+
+  ## handle_maybe_tag_open_end
+
+  defp handle_maybe_tag_open_end("\r\n" <> rest, line, _column, acc, state) do
+    handle_maybe_tag_open_end(rest, line + 1, state.column_offset, acc, state)
+  end
+
+  defp handle_maybe_tag_open_end("\n" <> rest, line, _column, acc, state) do
+    handle_maybe_tag_open_end(rest, line + 1, state.column_offset, acc, state)
+  end
+
+  defp handle_maybe_tag_open_end(<<c::utf8, rest::binary>>, line, column, acc, state)
+       when c in @space_chars do
+    handle_maybe_tag_open_end(rest, line, column + 1, acc, state)
+  end
+
+  defp handle_maybe_tag_open_end("/>" <> rest, line, column, acc, state) do
+    acc = reverse_attrs(acc, line, column + 2)
+    handle_text(rest, line, column + 2, [], put_self_close(acc), state)
+  end
+
+  defp handle_maybe_tag_open_end(">" <> rest, line, column, acc, state) do
+    case reverse_attrs(acc, line, column + 1) do
+      [{:tag, "script", _, _} | _] = acc ->
+        handle_script(rest, line, column + 1, [], acc, state)
+
+      [{:tag, "style", _, _} | _] = acc ->
+        handle_style(rest, line, column + 1, [], acc, state)
+
+      acc ->
+        handle_text(rest, line, column + 1, [], acc, state)
+    end
+  end
+
+  defp handle_maybe_tag_open_end("{" <> rest, line, column, acc, state) do
+    handle_root_attribute(rest, line, column + 1, acc, state)
+  end
+
+  defp handle_maybe_tag_open_end(<<>>, line, column, _acc, state) do
+    message = ~S"""
+    expected closing `>` or `/>`
+
+    Make sure the tag is properly closed. This may happen if there
+    is an EEx interpolation inside a tag, which is not supported.
+    For instance, instead of
+
+        <div id="<%= @id %>">Content</div>
+
+    do
+
+        <div id={@id}>Content</div>
+
+    If @id is nil or false, then no attribute is sent at all.
+
+    Inside {...} you can place any Elixir expression. If you want
+    to interpolate in the middle of an attribute value, instead of
+
+        <a class="foo bar <%= @class %>">Text</a>
+
+    you can pass an Elixir string with interpolation:
+
+        <a class={"foo bar #{@class}"}>Text</a>
+    """
+
+    raise ParseError, file: state.file, line: line, column: column, description: message
+  end
+
+  defp handle_maybe_tag_open_end(text, line, column, acc, state) do
+    handle_attribute(text, line, column, acc, state)
+  end
+
+  ## handle_attribute
+
+  defp handle_attribute(text, line, column, acc, state) do
+    case handle_attr_name(text, column, []) do
+      {:ok, name, new_column, rest} ->
+        acc = put_attr(acc, name, %{line: line, column: column})
+        handle_maybe_attr_value(rest, line, new_column, acc, state)
+
+      {:error, message, column} ->
+        meta = %{line: line, column: column}
+        raise_syntax_error!(message, meta, state)
+    end
+  end
+
+  ## handle_root_attribute
+
+  defp handle_root_attribute(text, line, column, acc, state) do
+    case handle_interpolation(text, line, column, [], state) do
+      {:ok, value, new_line, new_column, rest, state} ->
+        meta = %{line: line, column: column}
+        acc = put_attr(acc, :root, meta, {:expr, value, meta})
+        handle_maybe_tag_open_end(rest, new_line, new_column, acc, state)
+
+      {:error, message} ->
+        # We do column - 1 to point to the opening {
+        meta = %{line: line, column: column - 1}
+        raise_syntax_error!(message, meta, state)
+    end
+  end
+
+  ## handle_attr_name
+
+  defp handle_attr_name(<<c::utf8, _rest::binary>>, column, _buffer)
+       when c in @quote_chars do
+    {:error, "invalid character in attribute name: #{<<c>>}", column}
+  end
+
+  defp handle_attr_name(<<c::utf8, _rest::binary>>, column, [])
+       when c in @stop_chars do
+    {:error, "expected attribute name", column}
+  end
+
+  defp handle_attr_name(<<c::utf8, _rest::binary>> = text, column, buffer)
+       when c in @stop_chars do
+    {:ok, buffer_to_string(buffer), column, text}
+  end
+
+  defp handle_attr_name(<<c::utf8, rest::binary>>, column, buffer) do
+    handle_attr_name(rest, column + 1, [char_or_bin(c) | buffer])
+  end
+
+  ## handle_maybe_attr_value
+
+  defp handle_maybe_attr_value("\r\n" <> rest, line, _column, acc, state) do
+    handle_maybe_attr_value(rest, line + 1, state.column_offset, acc, state)
+  end
+
+  defp handle_maybe_attr_value("\n" <> rest, line, _column, acc, state) do
+    handle_maybe_attr_value(rest, line + 1, state.column_offset, acc, state)
+  end
+
+  defp handle_maybe_attr_value(<<c::utf8, rest::binary>>, line, column, acc, state)
+       when c in @space_chars do
+    handle_maybe_attr_value(rest, line, column + 1, acc, state)
+  end
+
+  defp handle_maybe_attr_value("=" <> rest, line, column, acc, state) do
+    handle_attr_value_begin(rest, line, column + 1, acc, state)
+  end
+
+  defp handle_maybe_attr_value(text, line, column, acc, state) do
+    handle_maybe_tag_open_end(text, line, column, acc, state)
+  end
+
+  ## handle_attr_value_begin
+
+  defp handle_attr_value_begin("\r\n" <> rest, line, _column, acc, state) do
+    handle_attr_value_begin(rest, line + 1, state.column_offset, acc, state)
+  end
+
+  defp handle_attr_value_begin("\n" <> rest, line, _column, acc, state) do
+    handle_attr_value_begin(rest, line + 1, state.column_offset, acc, state)
+  end
+
+  defp handle_attr_value_begin(<<c::utf8, rest::binary>>, line, column, acc, state)
+       when c in @space_chars do
+    handle_attr_value_begin(rest, line, column + 1, acc, state)
+  end
+
+  defp handle_attr_value_begin("\"" <> rest, line, column, acc, state) do
+    handle_attr_value_quote(rest, ?", line, column + 1, [], acc, state)
+  end
+
+  defp handle_attr_value_begin("'" <> rest, line, column, acc, state) do
+    handle_attr_value_quote(rest, ?', line, column + 1, [], acc, state)
+  end
+
+  defp handle_attr_value_begin("{" <> rest, line, column, acc, state) do
+    handle_attr_value_as_expr(rest, line, column + 1, acc, state)
+  end
+
+  defp handle_attr_value_begin(_text, line, column, _acc, state) do
+    message =
+      "invalid attribute value after `=`. Expected either a value between quotes " <>
+        "(such as \"value\" or \'value\') or an Elixir expression between curly brackets (such as `{expr}`)"
+
+    meta = %{line: line, column: column}
+    raise_syntax_error!(message, meta, state)
+  end
+
+  ## handle_attr_value_quote
+
+  defp handle_attr_value_quote("\r\n" <> rest, delim, line, _column, buffer, acc, state) do
+    column = state.column_offset
+    handle_attr_value_quote(rest, delim, line + 1, column, ["\r\n" | buffer], acc, state)
+  end
+
+  defp handle_attr_value_quote("\n" <> rest, delim, line, _column, buffer, acc, state) do
+    column = state.column_offset
+    handle_attr_value_quote(rest, delim, line + 1, column, ["\n" | buffer], acc, state)
+  end
+
+  defp handle_attr_value_quote(<<delim, rest::binary>>, delim, line, column, buffer, acc, state) do
+    value = buffer_to_string(buffer)
+    acc = put_attr_value(acc, {:string, value, %{delimiter: delim}})
+    handle_maybe_tag_open_end(rest, line, column + 1, acc, state)
+  end
+
+  defp handle_attr_value_quote(<<c::utf8, rest::binary>>, delim, line, column, buffer, acc, state) do
+    handle_attr_value_quote(rest, delim, line, column + 1, [char_or_bin(c) | buffer], acc, state)
+  end
+
+  defp handle_attr_value_quote(<<>>, delim, line, column, _buffer, _acc, state) do
+    message = """
+    expected closing `#{<<delim>>}` for attribute value
+
+    Make sure the attribute is properly closed. This may also happen if
+    there is an EEx interpolation inside a tag, which is not supported.
+    Instead of
+
+        <div <%= @some_attributes %>>
+        </div>
+
+    do
+
+        <div {@some_attributes}>
+        </div>
+
+    Where @some_attributes must be a keyword list or a map.
+    """
+
+    meta = %{line: line, column: column}
+    raise_syntax_error!(message, meta, state)
+  end
+
+  ## handle_attr_value_as_expr
+
+  defp handle_attr_value_as_expr(text, line, column, acc, %{braces: []} = state) do
+    case handle_interpolation(text, line, column, [], state) do
+      {:ok, value, new_line, new_column, rest, state} ->
+        acc = put_attr_value(acc, {:expr, value, %{line: line, column: column}})
+        handle_maybe_tag_open_end(rest, new_line, new_column, acc, state)
+
+      {:error, message} ->
+        # We do column - 1 to point to the opening {
+        meta = %{line: line, column: column - 1}
+        raise_syntax_error!(message, meta, state)
+    end
+  end
+
+  ## handle_interpolation
+
+  defp handle_interpolation("\r\n" <> rest, line, _column, buffer, state) do
+    handle_interpolation(rest, line + 1, state.column_offset, ["\r\n" | buffer], state)
+  end
+
+  defp handle_interpolation("\n" <> rest, line, _column, buffer, state) do
+    handle_interpolation(rest, line + 1, state.column_offset, ["\n" | buffer], state)
+  end
+
+  defp handle_interpolation("}" <> rest, line, column, buffer, %{braces: []} = state) do
+    value = buffer_to_string(buffer)
+    {:ok, value, line, column + 1, rest, state}
+  end
+
+  defp handle_interpolation(~S(\}) <> rest, line, column, buffer, state) do
+    handle_interpolation(rest, line, column + 2, [~S(\}) | buffer], state)
+  end
+
+  defp handle_interpolation(~S(\{) <> rest, line, column, buffer, state) do
+    handle_interpolation(rest, line, column + 2, [~S(\{) | buffer], state)
+  end
+
+  defp handle_interpolation("}" <> rest, line, column, buffer, state) do
+    {_pos, state} = pop_brace(state)
+    handle_interpolation(rest, line, column + 1, ["}" | buffer], state)
+  end
+
+  defp handle_interpolation("{" <> rest, line, column, buffer, state) do
+    state = push_brace(state, {line, column})
+    handle_interpolation(rest, line, column + 1, ["{" | buffer], state)
+  end
+
+  defp handle_interpolation(<<c::utf8, rest::binary>>, line, column, buffer, state) do
+    handle_interpolation(rest, line, column + 1, [char_or_bin(c) | buffer], state)
+  end
+
+  defp handle_interpolation(<<>>, _line, _column, _buffer, _state) do
+    {:error, "expected closing `}` for expression"}
+  end
+
+  ## helpers
+
+  @compile {:inline, ok: 2, char_or_bin: 1}
+  defp ok(acc, cont), do: {acc, cont}
+
+  defp char_or_bin(c) when c <= 127, do: c
+  defp char_or_bin(c), do: <<c::utf8>>
+
+  defp buffer_to_string(buffer) do
+    IO.iodata_to_binary(Enum.reverse(buffer))
+  end
+
+  defp text_to_acc(buffer, acc, line, column, context)
+
+  defp text_to_acc([], acc, _line, _column, _context),
+    do: acc
+
+  defp text_to_acc(buffer, acc, line, column, context) do
+    meta = %{line_end: line, column_end: column}
+
+    meta =
+      if context = get_context(context) do
+        Map.put(meta, :context, trim_context(context))
+      else
+        meta
+      end
+
+    [{:text, buffer_to_string(buffer), meta} | acc]
+  end
+
+  defp trim_context([:comment_start, :comment_end | [_ | _] = rest]), do: trim_context(rest)
+  defp trim_context(rest), do: rest
+
+  defp get_context([]), do: nil
+  defp get_context(context), do: Enum.reverse(context)
+
+  defp put_attr([{type, name, attrs, meta} | acc], attr, attr_meta, value \\ nil) do
+    attrs = [{attr, value, attr_meta} | attrs]
+    [{type, name, attrs, meta} | acc]
+  end
+
+  defp put_attr_value([{type, name, [{attr, _value, attr_meta} | attrs], meta} | acc], value) do
+    attrs = [{attr, value, attr_meta} | attrs]
+    [{type, name, attrs, meta} | acc]
+  end
+
+  defp reverse_attrs([{type, name, attrs, meta} | acc], line, column) do
+    attrs = Enum.reverse(attrs)
+    meta = %{meta | inner_location: {line, column}}
+    [{type, name, attrs, meta} | acc]
+  end
+
+  defp put_self_close([{type, name, attrs, meta} | acc]) do
+    meta = Map.put(meta, :self_close, true)
+    [{type, name, attrs, meta} | acc]
+  end
+
+  defp push_brace(state, pos) do
+    %{state | braces: [pos | state.braces]}
+  end
+
+  defp pop_brace(%{braces: [pos | braces]} = state) do
+    {pos, %{state | braces: braces}}
+  end
+
+  defp strip_text_token_fully(tokens) do
+    with [{:text, text, _} | rest] <- tokens,
+         "" <- String.trim_leading(text) do
+      strip_text_token_fully(rest)
+    else
+      _ -> tokens
+    end
+  end
+
+  defp raise_syntax_error!(message, meta, state) do
+    raise ParseError,
+      file: state.file,
+      line: meta.line,
+      column: meta.column,
+      description: message <> ParseError.code_snippet(state.source, meta, state.indentation)
+  end
+end
diff --git a/lib/beacon/template/heex/tokenizer.ex b/lib/beacon/template/heex/tokenizer.ex
index 0813c41ed..9710df215 100644
--- a/lib/beacon/template/heex/tokenizer.ex
+++ b/lib/beacon/template/heex/tokenizer.ex
@@ -1,10 +1,10 @@
 # DO NOT CHANGE THIS FILE
-# It a copy from https://github.com/phoenixframework/phoenix_live_view/blob/d0e46f5430d113269b8903a8b45b025d77532429/lib/phoenix_live_view/html_formatter.ex
-
-# Generates a nested list of token for a given HEEx template
+# It's a copy from https://github.com/phoenixframework/phoenix_live_view/blob/d0e46f5430d113269b8903a8b45b025d77532429/lib/phoenix_live_view/html_formatter.ex
 
 defmodule Beacon.Template.HEEx.Tokenizer do
-  alias Phoenix.LiveView.Tokenizer
+  # alias Phoenix.LiveView.Tokenizer
+  # TODO: use LiveView 0.20+ tokenizer
+  alias Beacon.Template.HEEx.LVTokenizer, as: Tokenizer
 
   defguard is_tag_open(tag_type)
            when tag_type in [:slot, :remote_component, :local_component, :tag]
diff --git a/lib/beacon/types/atom.ex b/lib/beacon/types/atom.ex
index 0d2004896..eaa608aca 100644
--- a/lib/beacon/types/atom.ex
+++ b/lib/beacon/types/atom.ex
@@ -7,17 +7,16 @@ defmodule Beacon.Types.Atom do
 
   def type, do: :atom
 
-  def cast(:any, site) when is_binary(site), do: {:ok, String.to_existing_atom(site)}
-  def cast(:any, site) when is_atom(site), do: {:ok, site}
-  def cast(:any, _), do: :error
-
   def cast(site) when is_binary(site), do: {:ok, String.to_existing_atom(site)}
   def cast(site) when is_atom(site), do: {:ok, site}
-  def cast(_), do: :error
-
-  def load(site) when is_binary(site), do: {:ok, String.to_existing_atom(site)}
+  def cast(site), do: {:error, message: "invalid site #{inspect(site)}"}
 
   def dump(site) when is_binary(site), do: {:ok, site}
   def dump(site) when is_atom(site), do: {:ok, Atom.to_string(site)}
-  def dump(_), do: :error
+  def dump(_site), do: :error
+
+  def equal?(site1, site2), do: site1 === site2
+
+  def load(site) when is_binary(site), do: {:ok, String.to_existing_atom(site)}
+  def load(_site), do: :error
 end
diff --git a/lib/beacon/types/binary.ex b/lib/beacon/types/binary.ex
index 7bbaeae41..8a00cbf78 100644
--- a/lib/beacon/types/binary.ex
+++ b/lib/beacon/types/binary.ex
@@ -9,14 +9,12 @@ defmodule Beacon.Types.Binary do
 
   def type, do: :binary
 
-  def cast(:any, term), do: {:ok, term}
-  def cast(term), do: {:ok, term}
+  def cast(term) when is_binary(term), do: {:ok, term}
+  def cast(term), do: {:ok, :erlang.term_to_binary(term)}
 
-  def load(binary) when is_binary(binary) do
-    {:ok, :erlang.binary_to_term(binary)}
-  end
+  def dump(term) when is_binary(term), do: {:ok, term}
+  def dump(term), do: {:ok, :erlang.term_to_binary(term)}
 
-  def dump(term) do
-    {:ok, :erlang.term_to_binary(term)}
-  end
+  def load(binary) when is_binary(binary), do: {:ok, :erlang.binary_to_term(binary)}
+  def load(_binary), do: :error
 end
diff --git a/lib/beacon/types/json_array_map.ex b/lib/beacon/types/json_array_map.ex
new file mode 100644
index 000000000..0c10f0bc7
--- /dev/null
+++ b/lib/beacon/types/json_array_map.ex
@@ -0,0 +1,92 @@
+defmodule Beacon.Types.JsonArrayMap do
+  @moduledoc """
+  Convert between json and map enforcing the data shape as array of objects/maps.
+  """
+
+  use Ecto.Type
+
+  def type, do: {:array, :map}
+
+  def cast(term) when is_map(term), do: {:ok, [term]}
+
+  def cast(term) when is_list(term) do
+    case validate(term) do
+      {true, list} ->
+        {:ok, list}
+
+      {false, list} ->
+        {:error, message: "expected a list of map or a map, got: #{inspect(list)}"}
+    end
+  end
+
+  def cast(term) when is_binary(term) do
+    case decode(term) do
+      {:ok, term} -> cast(term)
+      {:error, message} -> {:error, message: message}
+    end
+  end
+
+  def cast(term) do
+    {:error, message: "expected a list of map or a map, got: #{inspect(term)}"}
+  end
+
+  def dump(term) when is_map(term), do: {:ok, [term]}
+
+  def dump(term) when is_list(term) do
+    case validate(term) do
+      {true, list} ->
+        {:ok, list}
+
+      {false, _list} ->
+        :error
+    end
+  end
+
+  def dump(term) when is_binary(term) do
+    case decode(term) do
+      {:ok, term} -> dump(term)
+      {:error, _message} -> :error
+    end
+  end
+
+  def dump(_site), do: :error
+
+  def load(term) when is_map(term), do: {:ok, [term]}
+
+  def load(term) when is_list(term), do: {:ok, term}
+
+  def load(term) when is_binary(term) do
+    case decode(term) do
+      {:ok, term} -> load(term)
+      {:error, _message} -> :error
+    end
+  end
+
+  def load(_term), do: :error
+
+  defp validate(term) when is_list(term) do
+    {valid, list} =
+      Enum.reduce_while(term, {true, []}, fn
+        t, {_valid, list} when is_map(t) ->
+          {:cont, {true, [t | list]}}
+
+        t, {_, list} ->
+          {:halt, {false, [t | list]}}
+      end)
+
+    {valid, Enum.reverse(list)}
+  end
+
+  defp validate(term), do: {false, term}
+
+  defp decode(term) when is_binary(term) do
+    case Jason.decode(term) do
+      {:ok, term} ->
+        {:ok, term}
+
+      {:error, error} ->
+        message = Exception.message(error)
+        {:error, "expected a list of map or a map, got error: #{message}"}
+    end
+  end
+end
diff --git a/lib/beacon/types/site.ex b/lib/beacon/types/site.ex
index f3b315cb6..3175de36a 100644
--- a/lib/beacon/types/site.ex
+++ b/lib/beacon/types/site.ex
@@ -43,21 +43,17 @@ defmodule Beacon.Types.Site do
   @doc false
   def type, do: :atom
 
-  @doc false
-  def cast(:any, site) when is_binary(site), do: {:ok, String.to_existing_atom(site)}
-  def cast(:any, site) when is_atom(site), do: {:ok, site}
-  def cast(:any, _), do: :error
-
   @doc false
   def cast(site) when is_binary(site), do: {:ok, String.to_existing_atom(site)}
   def cast(site) when is_atom(site), do: {:ok, site}
-  def cast(_), do: :error
-
-  @doc false
-  def load(site) when is_binary(site), do: {:ok, String.to_existing_atom(site)}
+  def cast(site), do: {:error, message: "invalid site #{inspect(site)}"}
 
   @doc false
   def dump(site) when is_binary(site), do: {:ok, site}
   def dump(site) when is_atom(site), do: {:ok, Atom.to_string(site)}
-  def dump(_), do: :error
+  def dump(_site), do: :error
+
+  @doc false
+  def load(site) when is_binary(site), do: {:ok, String.to_existing_atom(site)}
+  def load(_site), do: :error
 end
diff --git a/lib/beacon_web/components/components.ex b/lib/beacon_web/components/components.ex
index c2f3def97..2998b681b 100644
--- a/lib/beacon_web/components/components.ex
+++ b/lib/beacon_web/components/components.ex
@@ -37,14 +37,14 @@ defmodule BeaconWeb.Components do
 
   ## Examples
 
-      <BeaconWeb.Components.image_set name="logo.jpg" width="200px" sources={["480w", "800w"]} sizes="(max-width: 600px) 480px, 800px"/>
+      <BeaconWeb.Components.image_set name="logo.jpg" width="200px" sources={["480w", "800w"]} sizes="(max-width: 600px) 480px, 800px" />
   """
 
   attr :class, :string, default: nil
   attr :sizes, :string, default: nil
-  attr :rest, :global
   attr :sources, :list, default: [], doc: "a list of usage_tags"
   attr :asset, :map, required: true, doc: "a MediaLibrary.Asset struct"
+  attr :rest, :global
 
   def image_set(assigns) do
     assigns =
diff --git a/mix.exs b/mix.exs
index 9f5ef3c7f..d7cb17509 100644
--- a/mix.exs
+++ b/mix.exs
@@ -51,10 +51,10 @@ defmodule Beacon.MixProject do
       {:image, "~> 0.32"},
       {:jason, "~> 1.0"},
       {:solid, "~> 0.14"},
-      {:phoenix, "~> 1.7"},
+      phoenix_dep(),
       {:phoenix_ecto, "~> 4.4"},
       {:phoenix_live_reload, "~> 1.3", only: :dev},
-      {:phoenix_live_view, "~> 0.19"},
+      phoenix_live_view_dep(),
       {:phoenix_pubsub, "~> 2.1"},
       {:phoenix_view, "~> 2.0", only: [:dev, :test]},
       {:plug_cowboy, "~> 2.6", only: [:dev, :test]},
@@ -67,19 +67,33 @@ defmodule Beacon.MixProject do
     ]
   end
 
+  defp phoenix_dep do
+    cond do
+      env = System.get_env("PHOENIX_VERSION") -> {:phoenix, env}
+      path = System.get_env("PHOENIX_PATH") -> {:phoenix, path}
+      :default -> {:phoenix, "~> 1.7"}
+    end
+  end
+
+  defp phoenix_live_view_dep do
+    cond do
+      env = System.get_env("PHOENIX_LIVE_VIEW_VERSION") -> {:phoenix_live_view, env}
+      path = System.get_env("PHOENIX_LIVE_VIEW_PATH") -> {:phoenix_live_view, path}
+      :default -> {:phoenix_live_view, "~> 0.19"}
+    end
+  end
+
   defp live_monaco_editor_dep do
-    if path = System.get_env("LIVE_MONACO_EDITOR_PATH") do
-      {:live_monaco_editor, path: path}
-    else
-      {:live_monaco_editor, "~> 0.1"}
+    cond do
+      path = System.get_env("LIVE_MONACO_EDITOR_PATH") -> {:live_monaco_editor, path: path}
+      :default -> {:live_monaco_editor, "~> 0.1"}
     end
   end
 
   defp mdex_dep do
-    if path = System.get_env("MDEX_PATH") do
-      {:mdex, path: path}
-    else
-      {:mdex, "~> 0.1"}
+    cond do
+      path = System.get_env("MDEX_PATH") -> {:mdex, path: path}
+      :default -> {:mdex, "~> 0.1"}
     end
   end
 
diff --git a/mix.lock b/mix.lock
index 45893618c..9ed5668cd 100644
--- a/mix.lock
+++ b/mix.lock
@@ -35,7 +35,7 @@
   "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
   "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"},
   "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"},
-  "mdex": {:hex, :mdex, "0.1.11", "83bac0b339811310362c86087c1ea1d37cf3190f41993a7de41fea81ccdbc8a1", [:mix], [{:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "303510829f3c59295e13b27992ef542356db8276a3a514f1369ae91afb62f60b"},
+  "mdex": {:hex, :mdex, "0.1.12", "bf56aa5dfc9b4bd51e98c38a7f57ae58c3a20f7b091287a121c1f5219b5d5824", [:mix], [{:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9a0151217cf27055753a747ee3d6aa10609eaa5299d6c82ce1445d32035abbea"},
   "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
   "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
   "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
diff --git a/priv/templates/install/seeds.exs b/priv/templates/install/seeds.exs
index 5c06b70d4..50d18bf74 100644
--- a/priv/templates/install/seeds.exs
+++ b/priv/templates/install/seeds.exs
@@ -91,8 +91,8 @@ Content.publish_layout(layout)
   <main>
     <h2>A blog</h2>
     <ul>
-      <li>Path Params Blog Slug: <%%= @beacon_path_params.blog_slug %></li>
-      <li>Live Data blog_slug_uppercase: <%%= @beacon_live_data.blog_slug_uppercase %></li>
+      <li>Path Params Blog Slug: <%%= @beacon_path_params["blog_slug"] %></li>
+      <li>Live Data blog_slug_uppercase: <%%= @beacon_live_data[:blog_slug_uppercase] %></li>
     </ul>
   </main>
   """
diff --git a/test/beacon/content_test.exs b/test/beacon/content_test.exs
index f5fe581e7..30feb83a7 100644
--- a/test/beacon/content_test.exs
+++ b/test/beacon/content_test.exs
@@ -262,6 +262,52 @@ defmodule Beacon.ContentTest do
       assert_receive :lifecycle_after_create_page
       assert_receive :lifecycle_after_publish_page
     end
+
+    test "save raw_schema" do
+      layout = layout_fixture(site: :raw_schema_test)
+
+      assert %Page{raw_schema: [%{"foo" => "bar"}]} =
+               Content.create_page!(%{
+                 site: "my_site",
+                 path: "/",
+                 template: "<p>page</p>",
+                 layout_id: layout.id,
+                 raw_schema: [%{"foo" => "bar"}]
+               })
+    end
+
+    test "update raw_schema" do
+      layout = layout_fixture(site: :raw_schema_test)
+
+      page =
+        Content.create_page!(%{
+          site: "my_site",
+          path: "/",
+          template: "<p>page</p>",
+          layout_id: layout.id,
+          raw_schema: [%{"foo" => "bar"}]
+        })
+
+      assert {:ok, %Page{raw_schema: [%{"@type" => "BlogPosting"}]}} = Content.update_page(page, %{"raw_schema" => [%{"@type" => "BlogPosting"}]})
+    end
+
+    test "validate raw_schema" do
+      layout = layout_fixture(site: :raw_schema_test)
+
+      assert {:error,
+              %{
+                errors: [
+                  raw_schema: {"expected a list of map or a map, got: [nil]", [type: Beacon.Types.JsonArrayMap, validation: :cast]}
+                ]
+              }} =
+               Content.create_page(%{
+                 site: "my_site",
+                 path: "/",
+                 template: "<p>page</p>",
+                 layout_id: layout.id,
+                 raw_schema: [nil]
+               })
+    end
   end
 
   describe "snippets" do
diff --git a/test/beacon/template/heex/json_encoder_test.exs b/test/beacon/template/heex/json_encoder_test.exs
index d54b5f864..430743b52 100644
--- a/test/beacon/template/heex/json_encoder_test.exs
+++ b/test/beacon/template/heex/json_encoder_test.exs
@@ -9,6 +9,10 @@ defmodule Beacon.Template.HEEx.JSONEncoderTest do
     assert encoded == expected
   end
 
+  test "nil template cast to empty string" do
+    assert_output(nil, [])
+  end
+
   test "html elements with attrs" do
     assert_output(~S|<div>content</div>|, [%{"attrs" => %{}, "content" => ["content"], "tag" => "div"}])
     assert_output(~S|<a href="/contact">contact</a>|, [%{"attrs" => %{"href" => "/contact"}, "content" => ["contact"], "tag" => "a"}])
diff --git a/test/beacon/template/heex/tokenizer_test.exs b/test/beacon/template/heex/tokenizer_test.exs
new file mode 100644
index 000000000..f10af2bd4
--- /dev/null
+++ b/test/beacon/template/heex/tokenizer_test.exs
@@ -0,0 +1,69 @@
+defmodule Beacon.Template.HEEx.TokenizerTest do
+  use ExUnit.Case, async: true
+
+  alias Beacon.Template.HEEx.Tokenizer
+
+  test "tokenizes a complex template" do
+    template = ~S|
+    <section>
+      <p><%= user.name %></p>
+      <%= if true do %>
+        <p>this</p>
+      <% else %>
+        <p>that</p>
+      <% end %>
+    </section>
+    <BeaconWeb.Components.image_set asset={@beacon_live_data[:img1]} sources={["480w"]} width="200px" />
+    |
+
+    assert Tokenizer.tokenize(template) == {
+             :ok,
+             [
+               {
+                 :tag_block,
+                 "section",
+                 [],
+                 [
+                   {:text, "\n      ", %{newlines: 1}},
+                   {:tag_block, "p", [], [{:eex, "user.name", %{column: 10, line: 3, opt: ~c"="}}], %{mode: :block}},
+                   {:text, "\n      ", %{newlines: 1}},
+                   {
+                     :eex_block,
+                     "if true do",
+                     [
+                       {
+                         [
+                           {:text, "\n        ", %{newlines: 1}},
+                           {:tag_block, "p", [], [{:text, "this", %{newlines: 0}}], %{mode: :block}},
+                           {:text, "\n      ", %{newlines: 1}}
+                         ],
+                         "else"
+                       },
+                       {
+                         [
+                           {:text, "\n        ", %{newlines: 1}},
+                           {:tag_block, "p", [], [{:text, "that", %{newlines: 0}}], %{mode: :block}},
+                           {:text, "\n      ", %{newlines: 1}}
+                         ],
+                         "end"
+                       }
+                     ]
+                   },
+                   {:text, "\n    ", %{newlines: 1}}
+                 ],
+                 %{mode: :block}
+               },
+               {:text, "\n    ", %{newlines: 1}},
+               {
+                 :tag_self_close,
+                 "BeaconWeb.Components.image_set",
+                 [
+                   {"asset", {:expr, "@beacon_live_data[:img1]", %{column: 44, line: 10}}, %{column: 37, line: 10}},
+                   {"sources", {:expr, "[\"480w\"]", %{column: 79, line: 10}}, %{column: 70, line: 10}},
+                   {"width", {:string, "200px", %{delimiter: 34}}, %{column: 89, line: 10}}
+                 ]
+               }
+             ]
+           }
+  end
+end
diff --git a/test/beacon/template/markdown_test.exs b/test/beacon/template/markdown_test.exs
index 0529a9444..579beca0c 100644
--- a/test/beacon/template/markdown_test.exs
+++ b/test/beacon/template/markdown_test.exs
@@ -6,9 +6,7 @@ defmodule Beacon.Template.MarkdownTest do
   test "convert to html" do
     expected = ~s|<h1>Test</h1>
 <p>Paragraph</p>
-<pre class="autumn highlight" style="background-color: #282C34; color: #ABB2BF;">
-<code class="language-elixir" translate="no">
-<span class="keyword" style="color: #E06C75;">defmodule</span> <span class="namespace" style="color: #61AFEF;">MyApp</span> <span class="keyword" style="color: #E06C75;">do</span>
+<pre class="autumn highlight" style="background-color: #282C34; color: #ABB2BF;"><code class="language-elixir" translate="no"><span class="keyword" style="color: #E06C75;">defmodule</span> <span class="namespace" style="color: #61AFEF;">MyApp</span> <span class="keyword" style="color: #E06C75;">do</span>
   <span class="comment" style="font-style: italic; color: #5C6370;">@</span><span class="comment" style="font-style: italic; color: #5C6370;">moduledoc</span> <span class="comment" style="font-style: italic; color: #5C6370;">&quot;Test&quot;</span>
 
   <span class="keyword" style="color: #E06C75;">def</span> <span class="function" style="color: #61AFEF;">foo</span><span class="" style="color: #ABB2BF;">,</span> <span class="string" style="color: #98C379;">do: </span><span class="string" style="color: #98C379;">:bar</span>
diff --git a/test/beacon/template/tokenizer_test.exs b/test/beacon/template/tokenizer_test.exs
deleted file mode 100644
index b45593324..000000000
--- a/test/beacon/template/tokenizer_test.exs
+++ /dev/null
@@ -1,55 +0,0 @@
-defmodule Beacon.Template.HEEx.TokenizerTest do
-  use ExUnit.Case, async: true
-
-  alias Beacon.Template.HEEx.Tokenizer
-
-  test "tokenizes a complex template" do
-    {:ok, result} =
-      Tokenizer.tokenize(
-        ~s(<section>\n  <p><%= user.name %></p>\n  <%= if true do %> <p>this</p><% else %><p>that</p><% end %>\n</section>\n<BeaconWeb.Components.image_set asset={@beacon_live_data[:img1]} sources={["480w"]} width="200px" />)
-      )
-
-    assert result ==
-             [
-               {
-                 :tag_block,
-                 "section",
-                 [],
-                 [
-                   {:text, "\n  ", %{newlines: 1}},
-                   {
-                     :tag_block,
-                     "p",
-                     [],
-                     [{:eex, "user.name", %{column: 6, line: 2, opt: ~c"="}}],
-                     %{mode: :block}
-                   },
-                   {:text, "\n  ", %{newlines: 1}},
-                   {
-                     :eex_block,
-                     "if true do",
-                     [
-                       {
-                         [
-                           {:text, " ", %{newlines: 0}},
-                           {:tag_block, "p", [], [{:text, "this", %{newlines: 0}}], %{mode: :block}}
-                         ],
-                         "else"
-                       },
-                       {[{:tag_block, "p", [], [{:text, "that", %{newlines: 0}}], %{mode: :block}}], "end"}
-                     ]
-                   },
-                   {:text, "\n", %{newlines: 1}}
-                 ],
-                 %{mode: :block}
-               },
-               {:text, "\n", %{newlines: 1}},
-               {:tag_self_close, "BeaconWeb.Components.image_set",
-                [
-                  {"asset", {:expr, "@beacon_live_data[:img1]", %{column: 40, line: 5}}, %{column: 33, line: 5}},
-                  {"sources", {:expr, "[\"480w\"]", %{column: 75, line: 5}}, %{column: 66, line: 5}},
-                  {"width", {:string, "200px", %{delimiter: 34}}, %{column: 85, line: 5}}
-                ]}
-             ]
-  end
-end
diff --git a/test/beacon/types/atom_test.exs b/test/beacon/types/atom_test.exs
new file mode 100644
index 000000000..c05bb2601
--- /dev/null
+++ b/test/beacon/types/atom_test.exs
@@ -0,0 +1,24 @@
+defmodule Beacon.Types.AtomTest do
+  use ExUnit.Case, async: true
+
+  alias Beacon.Types.Atom
+
+  _ = :site
+
+  test "cast" do
+    assert Atom.cast("site") == {:ok, :site}
+    assert Atom.cast(:site) == {:ok, :site}
+    assert Atom.cast(0) == {:error, [message: "invalid site 0"]}
+  end
+
+  test "dump" do
+    assert Atom.dump("site") == {:ok, "site"}
+    assert Atom.dump(:site) == {:ok, "site"}
+    assert Atom.dump(0) == :error
+  end
+
+  test "load" do
+    assert Atom.load("site") == {:ok, :site}
+    assert Atom.load(0) == :error
+  end
+end
diff --git a/test/beacon/types/binary_test.exs b/test/beacon/types/binary_test.exs
new file mode 100644
index 000000000..2fe8f7596
--- /dev/null
+++ b/test/beacon/types/binary_test.exs
@@ -0,0 +1,23 @@
+defmodule Beacon.Types.BinaryTest do
+  use ExUnit.Case, async: true
+
+  alias Beacon.Types.Binary
+
+  @term %{"foo" => :bar}
+  @binary :erlang.term_to_binary(@term)
+
+  test "cast" do
+    assert Binary.cast(@binary) == {:ok, @binary}
+    assert Binary.cast(@term) == {:ok, @binary}
+  end
+
+  test "dump" do
+    assert Binary.dump(@binary) == {:ok, @binary}
+    assert Binary.dump(@term) == {:ok, @binary}
+  end
+
+  test "load" do
+    assert Binary.load(@binary) == {:ok, @term}
+    assert Binary.load(@term) == :error
+  end
+end
diff --git a/test/beacon/types/json_array_map_test.exs b/test/beacon/types/json_array_map_test.exs
new file mode 100644
index 000000000..34ecb3271
--- /dev/null
+++ b/test/beacon/types/json_array_map_test.exs
@@ -0,0 +1,35 @@
+defmodule Beacon.Types.JsonArrayMapTest do
+  use ExUnit.Case, async: true
+
+  alias Beacon.Types.JsonArrayMap
+
+  @map %{"foo" => "bar"}
+
+  test "cast" do
+    assert JsonArrayMap.cast([]) == {:ok, []}
+    assert JsonArrayMap.cast(@map) == {:ok, [@map]}
+    assert JsonArrayMap.cast([@map]) == {:ok, [@map]}
+    assert JsonArrayMap.cast(~s|[{"foo": "bar"}]|) == {:ok, [@map]}
+    assert JsonArrayMap.cast(nil) == {:error, [{:message, "expected a list of map or a map, got: nil"}]}
+    assert JsonArrayMap.cast([1]) == {:error, [{:message, "expected a list of map or a map, got: [1]"}]}
+    assert JsonArrayMap.cast("") == {:error, [message: "expected a list of map or a map, got error: unexpected end of input at position 0"]}
+  end
+
+  test "dump" do
+    assert JsonArrayMap.dump([]) == {:ok, []}
+    assert JsonArrayMap.dump(@map) == {:ok, [@map]}
+    assert JsonArrayMap.dump([@map]) == {:ok, [@map]}
+    assert JsonArrayMap.dump(~s|[{"foo": "bar"}]|) == {:ok, [@map]}
+    assert JsonArrayMap.dump(nil) == :error
+    assert JsonArrayMap.dump([1]) == :error
+    assert JsonArrayMap.dump("") == :error
+  end
+
+  test "load" do
+    assert JsonArrayMap.load(@map) == {:ok, [@map]}
+    assert JsonArrayMap.load([@map]) == {:ok, [@map]}
+    assert JsonArrayMap.load(~s|[{"foo": "bar"}]|) == {:ok, [@map]}
+    assert JsonArrayMap.load(nil) == :error
+    assert JsonArrayMap.load("") == :error
+  end
+end
diff --git a/test/beacon/types/site_test.exs b/test/beacon/types/site_test.exs
new file mode 100644
index 000000000..924e4fb0f
--- /dev/null
+++ b/test/beacon/types/site_test.exs
@@ -0,0 +1,27 @@
+defmodule Beacon.Types.SiteTest do
+  use ExUnit.Case, async: true
+
+  alias Beacon.Types.Site
+  import Beacon.Types.Site, only: [valid?: 1]
+
+  doctest Site, only: [valid?: 1]
+
+  _ = :site
+
+  test "cast" do
+    assert Site.cast("site") == {:ok, :site}
+    assert Site.cast(:site) == {:ok, :site}
+    assert Site.cast(0) == {:error, [message: "invalid site 0"]}
+  end
+
+  test "dump" do
+    assert Site.dump("site") == {:ok, "site"}
+    assert Site.dump(:site) == {:ok, "site"}
+    assert Site.dump(0) == :error
+  end
+
+  test "load" do
+    assert Site.load("site") == {:ok, :site}
+    assert Site.load(0) == :error
+  end
+end