My Resume Is a LiveView App
Published: 2025-05-14
Last updated: 2025-05-14
Reading time: ~11 min
Tags:
elixir
phoenix
Resumes can be a pain to maintain. They’re either locked away in Word documents, fragile LaTeX templates, or scattered across PDFs with no version control. As a developer who values clean structure, live updates, modularity, and good design, I wanted something better—so I built my resume as a fully live, styled, and printable Phoenix LiveView app. No database. No external CMS. Just Elixir, TailwindCSS (via DaisyUI), and the power of functional code. Here’s how I did it—and why you might want to consider the same approach.
Why I Chose to Build My Resume with LiveView
Before writing my resume in Elixir and Phoenix, I searched the web for existing solutions to the extensible, modular, versioned resume problem. I found a few options, the most prominent being RxResume and JSON Resume. I actually created an account on RxResume and tried creating my resume. I found it to be a very polished experience and excellent for most use cases. However, I wanted to own my data and version my resume content rather than the finished product. This led me to stumble upon JSON Resume. I liked the well-defined format, but didn't want to use JSON. I wanted to build something in pure Elixir that would
- Emphasize modularity and flexibility
- Support Git-based versioning
- Cleanly separate resume content and presentation
- Provide a streamlined development experience
- Make resume writing and editing fun
Eventually I decided I would build my own solution for myself. I might open-source the code in the near future, but for now, I am keeping my resume content along with the app code for easier versioning.
Designing Resume Data
I bootstrapped this project with phx_new
version 1.8.0-rc3, which at the time of writing, had the latest and greatest of Phoenix features and updates. I really liked that it had DaisyUI and a theme-changer component out-of-the-box. It's nice to get dark-mode support free for easier nighttime resume writing.
The first step was to design my data. A good data definition goes a long way in thinking about and solving problems, so I opted for creating structs for all relevant parts of a Resume
. This way, I can expect certain keys in my resume data and also receive compile time checks.
defmodule LiveResume.Resumes.Resume do
@moduledoc """
Represents a Resume with basic personal and professional information.
"""
alias LiveResume.Resumes.{
Bio,
EducationalExperience,
Certification,
Skill,
Project,
WorkExperience,
Link
}
@type t :: %__MODULE__{
bio: Bio.t(),
education: [EducationalExperience.t()],
certifications: [Certification.t()],
skills: [Skill.t()],
projects: [Project.t()],
work_experiences: [WorkExperience.t()],
social_links: [Link.t()]
}
defstruct bio: %Bio{},
education: [],
skills: [],
projects: [],
certifications: [],
work_experiences: [],
social_links: []
end
Here is my definition of a Resume. I won't show all struct code here for the sake of brevity.
Loading Resume Data at Runtime
Now that I had an outline of what my resume content will look like, I needed a way to store it a file and load it at runtime for use in my app. Elixir has a useful function called Code.eval_file/2
which will evaluate the expressions in a file and return the result. Now all I needed to do is write a Resume
inside of a my_resume.exs
file. But where would I store this file? As I mentioned before, I wanted my resume files to live in the codebase along with my app code for maximum simplicity and convenience. My main priority right now is create an app that is useful to me and solves a real problem I've been having, not to share my code. In Phoenix projects, static files typically live inside of the project priv/
directory so I created priv/resumes
for holding my resume .exs
files. Here is an example of what my resume files look like:
# priv/resumes/test_resume.exs
alias LiveResume.Resumes.{
Resume,
Bio,
Certification,
EducationalExperience,
Skill,
Project,
WorkExperience,
Link
}
%Resume{
bio: %Bio{
name: "Your Name Here",
headline: "Full Stack Developer",
address: "100 Tawheed Way, Sometown, TN",
phone: "(555)-555-5555",
email: "me@me.com"
},
education: [
%EducationalExperience{
school: "Northeastern University",
location: "Boston, Massachusetts",
degree: "Bachelor of Arts",
field: "Computer Science",
start_date: ~D[2017-09-05],
end_date: ~D[2021-04-16],
grade: "cum laude",
note: "Member of the Honors Program",
},
%EducationalExperience{
school: "Madison Academic Magnet High School",
location: "Jackson, TN",
degree: "High School Diploma",
note: "Honors",
start_date: ~D[2013-08-01],
end_date: ~D[2017-05-20]
}
],
certifications: [
%Certification{
name: "Certified in Cybersecurity (CC)",
issuer: "ISC2",
issue_date: ~D[2025-02-16],
expiration_date: ~D[2028-02-29]
}
],
skills: [
%Skill{name: "Elixir", strength: 8},
%Skill{name: "Python", strength: 7},
%Skill{name: "C/C++", strength: 7},
%Skill{name: "JavaScript", strength: 7},
%Skill{name: "Bash", strength: 6},
%Skill{name: "HTML", strength: 7},
%Skill{name: "Phoenix", strength: 7},
%Skill{name: "ReactJS", strength: 4},
%Skill{name: "React Native", strength: 3},
%Skill{name: "TailwindCSS", strength: 5},
%Skill{name: "Linux (Ubuntu, Debian, Kali)", strength: 9},
%Skill{name: "Docker", strength: 5},
%Skill{name: "Git", strength: 7}
],
projects: [
%Project{
name: "LiveCalculator",
description: """
LiveCalculator is a LiveView calculator app. It works great!
""",
completed_date: ~D[2025-04-20],
link: %Link{
url: "https://github.com/my_account/live_calc",
name: "github.com/my_account/live_calc"
},
skills: [
%Skill{name: "Elixir"},
%Skill{name: "Phoenix"},
%Skill{name: "LiveView"},
%Skill{name: "OTP"},
%Skill{name: "JavaScript"},
%Skill{name: "TailwindCSS"},
%Skill{name: "Docker"},
%Skill{name: "Fly.io"}
],
details: [
"Built a LiveView calculator app using Elixir, Phoenix, and OTP",
"Leveraged Phoenix Channels and GenServers to handle all client interactions",
"Used PicoCSS for UI styling"
]
},
%Project{
name: "Bulls",
description: "",
completed_date: ~D[2025-05-13],
skills: [
%Skill{name: "Elixir"},
%Skill{name: "Phoenix"},
%Skill{name: "LiveView"},
%Skill{name: "TailwindCSS"}
],
details: [
"Built an realtime multiplayer number guessing game",
"Emphasizes idiomatic Elixir architecture and clean, component-driven design"
]
}
],
work_experiences: [
%WorkExperience{
organization: "Some Company",
location: "Sometown, CA",
position: "Software Engineer I, II",
start_date: ~D[2021-12-01],
end_date: nil,
details: [
"Held a software engineering position on the Software Team",
"Developed software features, investigated operational issues, and fixed software problem reports",
"Oversaw cybersecurity, ensuring compliance with industry-standard practices and hardening all network access points",
]
},
%WorkExperience{
organization: "Another Company",
location: "Sometown, ID",
position: "Full Stack Software Development Intern",
start_date: ~D[2020-08-01],
end_date: ~D[2020-12-01],
details: [
"Developed core gameplay and UI features for a multiplayer party game built with React Native",
%Link{name: "App selected as Apple App Store App of the Day in December 2022", url: "https://example.com"},
"Practiced Behavior Driven Development and wrote integration tests using Jest"
]
}
],
social_links: [
%Link{url: "https://github.com/my_account", name: "GitHub"},
%Link{url: "https://linkedin.com/in/my_account", name: "LinkedIn"}
]
}
And with that, we've represented a resume in pure Elixir that is ready to be version-controlled and used in our LiveView; no database needed. With the Model done, now we move on to the LiveView.
Generating my LiveViews
At this point, I knew that I would need 2 LiveViews: one that will index all of my available resumes, and another that will show a resume. I can bootstrap both quickly with Phoenix's generator mix phx.gen.live Resumes Resume resumes throwaway:string
rather than writing all of this myself. Note that I used a generator even though I don't have a database or Ecto schemas mainly to get the preliminary Context and LiveView starting point. The 'throwaway' field was added because the generator requires it, but it was easy to remove all database related code thereafter. I also did this because in case the app's needs grow or I do decide to use a database backend, then I have the necessary scaffolding already in place. With that done, I now have my routes and initial LiveViews setup. My router now looks like this:
scope "/", LiveResumeWeb do
pipe_through :browser
# get "/", PageController, :home
live_session :default do
live "/", ResumeLive.Index, :index
live "/resumes/:id", ResumeLive.Show, :show
# live "/resumes/new", ResumeLive.Form, :new
# live "/resumes/:id/edit", ResumeLive.Form, :edit
end
end
Customizing the LiveViews
Phoenix uses 'layouts' to separate common page structure out of different views. This made it really easy to get started designing my pages. In fact, I mostly kept the initial app/1
layout in layouts.ex
as is with a few edits. All I needed to do on my index page is list all resumes.
defmodule LiveResumeWeb.ResumeLive.Index do
use LiveResumeWeb, :live_view
alias LiveResume.Resumes
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "All Resumes")
|> assign(:resume_files, Resumes.list_resumes_friendly())}
end
end
Now that our index LiveView has an assigns of @resume_files
, I just need to render them. Here is my thin home page:
# index.html.heex
<Layouts.app flash={@flash}>
<.header>All Resumes</.header>
<ul class="menu menu-lg bg-base-200 rounded-box w-full">
<%= for resume_file <- @resume_files do %>
<li class="flex flex-row items-center">
<.link navigate={~p"/resumes/#{resume_file}"} class="grow">
<.icon name="hero-document-text" class="size-6" />
{resume_file}
</.link>
</li>
<% end %>
</ul>
</Layouts.app>
It looks like this. Simple and clean, just what I was going for.

Building the Main Resume LiveView
Now it's time for the core of the entire app: the Resume LiveView. My vision was to enable myself to design my resume as a HEEx template. I've grown to really enjoy the Phoenix workflow of making template edits and seeing the changes refresh live. It's fast, effective, and fun. What I've learned over the years writing and editing my resume in documents and word editors is that they can be clunky at times and it's hard to get everything the way you want without researching each editor's specific way of doing things. As a Full Stack developer, the design language I know and use frequently is HTML and CSS. I don't want to learn how to format a document in MS Word, Google Docs, or LibreOffice individually. With DaisyUI and TailwindCSS, I could control every aspect of the display in a familiar way.
The one issue I kept turning over in my mind was how I would export the resume when finished. Ideally, I would like to export to PDF, but after doing some searching, I found that I would need some external dependencies for that and I did not want to go that route at this time. My solution to this is that when finished, I would just print the webpage and use the print controls to save the final resume as a PDF. The option to 'Save to PDF' when printing exists on all systems and the printing options are decently sufficient for controlling margins and other paging idiosyncrasies.
With my user-story in mind, now I could start implementing my dream resume builder. I wanted to take advantage of Phoenix's layout system to create swappable templates for my resume content. I wanted to use separate HEEx templates per resume layout, but how could I render different HEEx templates dynamically when the :show
Live action can only render one template? My solution to this issue was inspired by how Phoenix itself handles routes in the application.
Dynamically Rendering Templates within a Live Action
If you've ever noticed at the very bottom of your web Context (the one named '<MyApp>Web'), there is a small but powerful function that sits at the heart of routing in our application.
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
Kernel.apply/2
calls a function from a module with arguments. Phoenix uses this figure out which template (don't forget that templates are really just functions) from your View to render based on the actions you defined in your router. We can reuse this idea for our resume templates.
First, I added a directory specifically for resume layouts in components/layouts/resumes
which would hold each resume template.

I then created a small wrapper ResumeLayouts
module for interfacing with it.
defmodule LiveResumeWeb.ResumeLayouts do
@moduledoc """
This module holds the Resume layouts.
See the `layouts/resumes` directory for all templates available.
"""
use LiveResumeWeb, :html
embed_templates "layouts/resumes/*"
end
Now, each template we defined in components/layouts/resumes
is a function inside of the LiveResumeWeb.ResumeLayouts
module. We can render them by their file name as an atom (without the file extension), and this is exactly what I did inside of my show LiveView.
# show.html.heex
<%= if !@resume do %>
<Layouts.app :if={!@resume} flash={@flash}></Layouts.app>
<% else %>
{apply(LiveResumeWeb.ResumeLayouts, String.to_atom(@template), [assigns])}
<% end %>
But where did the @template
assign come from? I parse it out from the URL as a query parameter in every request to the LiveView and store it in the socket.
# show.ex
@impl true
def handle_params(params, _uri, socket) do
template = Map.get(params, "template")
{:noreply,
socket
|> set_template(template)}
end
defp set_template(socket, nil), do: assign(socket, :template, "default")
defp set_template(socket, template), do: assign(socket, :template, template)
A simple test in the browser shows that our dynamic resume template rendering is working, so now we can continue building out our templates, and the editor UI.
Building Out the UI
Rendering the resume in the template is where the real work takes place, so I took some time to carefully design a resume that I liked and worked for me. There's not much to say about it since it is just styling with DaisyUI and TailwindCSS. Here's how our test resume from above looks like using the default template I designed:

Here's what it looks like in dark mode:

It's nice to be able to build out your resume using the stack you know and love and seeing updates in real-time as the whole thing comes together as each component gets built.
I added a drawer UI component from DaisyUI to hold the theme-changer, template selection, and other settings. This way, the main resume design experience is kept clean with nothing in the way so I can focus on designing my resume.


The 'Reload Resume' button re-evaluates our resume .exs
file in case we made any content changes to it.
Exporting
With my resume done, I'm ready to export to PDF or print if I want. Using the browser's print dialog, I can adjust the page settings before saving to PDF. I like to set the margins to 0.5 inches.

You may have noticed that the icons are missing in the final exported resume. This is because the hero icons included in Phoenix by default are SVGs that are not inlined but are instead fetched. When the browser tries to print (i.e. export) the page, since the SVGs are not included in the markup inline, they are skipped. There are some workarounds to this which I left for another day.
Challenges and Reflections
Throughout the project there were a few challenges that needed tailored solutions. From them were
- Navigating LiveView layout overrides
- Making the UI printable without compromising design
- Building a flexible component-based system for layouts
- Balancing developer UX and maintainability
In overcoming each of them, I tried to balance simplicity and extensibility. In case I want to take the project further by adding new resume fields, designing new resume templates, or using a database for persistence, I have the ability do so fairly easily without breaking existing features or templates.
Wrapping Up
Right now, this project is closed source, mainly because I am keeping my resume data with personal information alongside the application code for developer convenience. However, in the future, I might abstract the resume parsing and layout features into a separate library that will be release open-source for other developers if they are interested. I am also working on solving the inline SVG issue during resume export.
LiveResume solved a real problem for me and it's how I write, design, and version my resumes from now on. I'm really enjoying designing my resumes in it especially with the tight feedback loop of Phoenix live reloading and endless customization options with DaisyUI and TailwindCSS.
I'm working on some other projects right now as well. Stay tuned for more app breakdowns.