<!-- LLM_VERSION_INFO
FORMAT: text/markdown
CONTENT_TYPE: article
ORIGINAL_URL: https://fly.io/phoenix-files/persistent-forms-with-your-url-on-liveview
ALTERNATE_VERSION: phoenix-files/persistent-forms-with-your-url-on-liveview/index.html (text/html)
EXTRACTION_DATE: 2026-04-18T22:24:22.576Z

This is the markdown version with text-only content (images converted to alt-text).
For rich formatting with images, request the HTML version at: phoenix-files/persistent-forms-with-your-url-on-liveview/index.html
-->

Author
  
Name  
Lubien  
[@joao_lubien](https://twitter.com/joao_lubien)  
  
Image by [Annie Ruygt](https://annieruygtillustration.com/)

In this article we talk about syncing LiveView state with URL. Fly.io is a great place to run your Phoenix LiveView applications! Check out how to [get started](/content/docs/elixir/index.html).

## Problem

Say you have a simple posts search form LiveView like this one:

Whenever users change the title or author on the search form you perform the search just fine but whenever you hit the refresh button your filters are emptied, poof. We did not persist those in any way, that’s why.

How can we persist our filters so that a page refresh would not make us have to do it all over again?

## Solution

We will use a simple trick to sync your URL query string with you LiveView filters using nothing but [`push_patch/2`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_patch/2) and `handle_params/3`.

# Run your forms on Fly.io

You can host your LiveView form apps here on Fly.io and get free SSL so users can see the lock icon on their browsers when they also see their URL change too.

## The initial state

It’s very likely you used Phoenix’s generators so your code must look almost like this on your `index.ex`:

```elixir
defmodule FormUrlRecipeWeb.PostLive.Index do
  use FormUrlRecipeWeb, :live_view

alias FormUrlRecipe.Blog
  alias FormUrlRecipe.Blog.Post

@impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, posts: [], authors: [])}
  end

@impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

# Other actions omitted here, lets focus on the list

defp apply_action(socket, :index, _params) do
    changeset = Blog.change_post(%Post{})

socket
    |> assign(:page_title, "Listing Posts")
    |> assign(:post, nil)
    |> assign(:posts, Blog.search_posts(%{}))
    |> assign(:authors, Blog.list_authors())
    |> assign(:changeset, changeset)
  end

@impl true
  def handle_event("search_posts", %{"post" => attrs}, socket) do
    changeset = Blog.change_post(%Post{}, attrs)

{:noreply,
      socket
      |> assign(:changeset, changeset)
      |> assign(:posts, Blog.search_posts(attrs))
    }
  end

# ...
end
```

As you can see there’s two places who search for posts using `Blog.search_posts`. The first is inside `apply_action/3` which runs from `handle_params/3`, those happen when you open the page, and the other one is inside the `handle_event("search_posts", ...)` which is triggered from our form component.

We can simplify this code and centralize where posts are loaded from on `apply_action/3` by simply making our `handle_event/3` just send you back to the same LiveView through `push_patch/3` like this:

```elixir
@impl true
def handle_event("search_posts", %{"post" => attrs}, socket) do
  {:noreply,
    socket
    |> push_patch(to: Routes.post_index_path(socket, :index, attrs))
  }
end
```

Assume we change the author to Michael, you will be sent to `/posts?author=Michael&title=`. Now we just need to start parsing the URL into a attributes we can fill our changeset and our search function:

```elixir
defp apply_action(socket, :index, params) do
  # Changed these two lines below
  attrs = Map.take(params, ["title", "author"])
  changeset = Blog.change_post(%Post{}, attrs)

socket
  |> assign(:page_title, "Listing Posts")
  |> assign(:post, nil)
  # Changed this line below
  |> assign(:posts, Blog.search_posts(attrs))
  |> assign(:authors, Blog.list_authors())
  |> assign(:changeset, changeset)
end
```

And that’s it! We now sync URL with our forms plus we refactored our LiveView to deduplicate the search being run. Don’t believe me? Here’s the [commit URL](https://github.com/fly-apps/phoenix-recipe-sync-url-with-form/commit/70c163413017f89023977f4abe8935146a3bca9e).

## Notes

It’s worth mentioning Phoenix will understand any empty field as empty string so your params might look like `%{"title" => "", "author" => "Lubien"}`. For my search functionality to work the way I wanted [I’ve filtered any `nil` or `""` inside the Blog.search_posts function](https://github.com/fly-apps/phoenix-recipe-sync-url-with-form/blob/70c163413017f89023977f4abe8935146a3bca9e/lib/form_url_recipe/blog.ex#L43-L50).
