Nex - 0.3.x Design Philosophy: The Evolution of Minimalism

Nex - A minimalist web framework for indie hackers and startups

A Note Before We Begin

This document records my thoughts during the development of Nex.

Special thanks to the friends from Reddit (r/elixir) and Elixir Forum. Your feedback, questions, suggestions, and even criticism have helped me gradually understand what Nex should be. Without you, there would be no 0.3.x refactor.

I also want to thank the teams behind Next.js, Phoenix, and HTMX. Your work has been an endless source of inspiration.


TL;DR

Simply put, 0.2.x was too complex: 4 different use statements, confusing API parameters, and unconventional directory naming.

With 0.3.x, I decided to subtract:

  • Only one use Nex.
  • API parameters reduced to just query and body, like Next.js.
  • Directories renamed back to the familiar components/.
  • Streaming responses became a simple function Nex.stream/1.

The result: less code, fewer concepts, and a smoother development experience.


The Core Problem: Finding Our Identity

While developing Nex 0.2.x, I was genuinely conflicted. I kept asking myself: What should Nex actually be? I tried many approaches, referenced many frameworks, but always felt something was missing.

The Dilemma of 0.2.x

Look at this 0.2.x code:

defmodule MyApp.Pages.Index do
  use Nex.Page  # Explicitly declare this is a Page
end

defmodule MyApp.Api.Users do
  use Nex.Api  # Explicitly declare this is an Api
end

defmodule MyApp.Partials.Card do
  use Nex.Partial  # Explicitly declare this is a Partial
end

This design looked rigorous, but it was exhausting to write. Since files are already in the pages/ directory, why should I have to tell the framework again, “This is a Page”?

Learning from Next.js

Later, I revisited Next.js. Its greatest strength is convention over configuration. You don’t need to write any configuration code—just put files in the right place.

This is what Nex should be: let developers focus on business logic and write less boilerplate.


Decision 1: One use Nex for Everything

The Awkwardness Before

In 0.2.x, not only did you have to remember 4 different modules, you also had to understand the differences between them. This was entirely artificial cognitive burden.

The Approach Now

0.3.x uses Elixir’s macro system to automatically infer module types based on file paths.

# 0.3.x - Much cleaner
defmodule MyApp.Pages.Index do
  use Nex
end

defmodule MyApp.Api.Users do
  use Nex
end

While this is a Breaking Change, it makes the code look much cleaner.


Decision 2: Rename partials/ to components/

This was actually a long-overdue correction.

I initially used partials because I was influenced by Rails and thought it had more of a “server-side rendering” flavor. But the reality is, the modern frontend world (React, Vue, Svelte) and Phoenix 1.7+ all use components.

Forcing partials only confused new users and offered no benefits. So we embraced the change and switched back to the familiar components.


Decision 3: Writing REST APIs Is Finally Easy

The Pain Point

Writing APIs in 0.2.x was torture. I had 4 places to put parameters: params, path_params, query_params, body_params. Developers had to think about where each parameter came from, adding cognitive burden.

Every time you wrote code, you had to wonder:

  • “Is this id in the path or in the query?”
  • “Should I use params or query_params?”
  • “What’s the difference between body_params and params?”

Learning from Next.js

I looked at how Next.js does it. They offer only two options, yet cover all scenarios:

  1. req.query: Handles all GET request parameters (whether in the path or after the ?)
  2. req.body: Handles all POST/PUT data

This is brilliantly simple. Developers only care about “Do I need to fetch data from the URL (Query)” or “Do I need to fetch data from the body (Body)”.

So in 0.3.x, we do the same. The framework automatically unifies path parameters (like /users/:id) and query parameters (like ?page=1) into req.query:

def get(req) do
  # Both path parameter :id and query parameter ?page are here
  id = req.query["id"]
  page = req.query["page"]
end

def post(req) do
  # All submitted data is here
  user = req.body["user"]
end

This “no-brainer” experience is what a good framework should provide.


Decision 4: Streaming Responses Are First-Class Citizens

In 2025, if a web framework requires effort to support SSE (Server-Sent Events), it’s definitely outdated.

In 0.2.x, you needed use Nex.SSE and had to follow specific function signatures. But in the age of AI applications, streaming responses should be a standard capability available everywhere.

Now you can return a stream from anywhere:

def get(req) do
  Nex.stream(fn send ->
    send.("Hello")
    Process.sleep(1000)
    send.("World")
  end)
end

Simple and direct, no magic tricks.


Summary: Finding Our Identity

When developing 0.1.x and 0.2.x, I was a bit greedy. I wanted to combine Phoenix’s power, Next.js’s simplicity, and Rails’s classics all together. The result was a “Frankenstein” framework.

By 0.3.x, I finally figured it out: Nex should not try to be another Phoenix.

The Elixir community already has Phoenix, a perfect industrial-grade framework. Nex’s mission should be to provide a simple and lightweight alternative (core code < 500 lines). It should be like Next.js, enabling developers (especially indie developers) to rapidly build usable products.

This is the entire point of Nex 0.3.x: Embrace simplicity, return to developer intuition.


Future Outlook

This refactor is not just about API changes, but a shift in design philosophy

Next Steps

  1. Exploring Datastar Integration

    • Monitor Datastar’s development as a Hypermedia framework
    • Evaluate whether it can provide finer-grained state updates than HTMX
    • Stay open to emerging technologies, but prioritize core DX improvements
  2. Ultimate Developer Experience (DX)

    • Make the framework “better to use”, not “more features”
    • More comprehensive documentation and real-world examples

Core Values Remain Unchanged

No matter how Nex evolves, these core principles won’t change:

  • :white_check_mark: Minimal: Least code, maximum productivity
  • :white_check_mark: Modern: Aligned with modern framework best practices
  • :white_check_mark: Practical: Solving real-world problems

A Word to Developers

About Breaking Changes

Nex is currently in an early, fast-moving iteration phase. To pursue the ultimate developer experience, breaking changes may happen at any time.

But I promise: I will document the thinking and reasoning behind every refactor in detail.

It’s not about change for change’s sake, but about exploring the best development experience for Elixir. I hope that by sharing these thoughts, we can communicate, learn together, and collectively refine a truly great framework.

Rather than giving a cold “upgrade guide”, I prefer to tell you “why I’m doing this”.

My Promise to Users

  1. Minimal API: Only need to learn use Nex and a few response functions
  2. Familiar Developer Experience: If you know Next.js, Nex’s API design will feel natural
  3. Comprehensive Documentation: Complete tutorials from beginner to advanced
  4. Active Community: We will continue to improve and support

Nex 0.3.x - Minimal, Modern, Practical Elixir Web Framework

Let’s build better web applications together! :rocket:

Nex Github Repo: GitHub - gofenix/nex: Nex – A minimalist web framework for indie hackers and startups

Quick Start:

# Install the project generator
mix archive.install hex nex_new

# Create a new project
mix nex.new my_app
cd my_app

# Start development server
mix nex.dev
3 Likes

Nice! These changes look great

1 Like

I love minimalism, but minimalism never sells. As a indie hacker, why would I adopt a simple and lightweight framework (core code < 500 lines) when I can write the 500 lines myself and have it exactly my way? I would suggest you find a niche and optimize for it.

Minimalism in code size is nice, but only to the author himself. Minimalism on the API surface area will be more valuable to the users.

3 Likes

Fair point! The line count isn’t the goal - it’s a byproduct.

What I’m really focused on:
• Simple, elegant API (DX over code size)
• Zero boilerplate
• File-based routing
• JSON API design that naturally leads to clean REST patterns
• Rapidly building web pages that work seamlessly with REST APIs

The idea is: if the framework lets you quickly build REST APIs and web pages that naturally work together, without configuration overhead, that’s where the real value is. Less time fighting the framework, more time on actual features.

2 Likes

Yeah I’m with you. Frameworks win me over with strong opinions. So if I’m sold on the conventions then I can supercharge my productivity with the framework.

I’m looking for a DSL that my brain loves.

Can’t wait to see how you explore this idea. Phoenix’s router is sophisticated and performant but not the most intuitive one to use or extend.

1 Like

Nex - 0.3.x Design Philosophy: The Evolution of Minimalism - #3 by derek-zhou
I love minimalism, but minimalism never sells. As a indie hacker, why would I adopt a simple and lightweight framework (core code < 500 lines) when I can write the 500 lines myself and have it exactly my way? I would suggest you find a niche and optimize for it.

Minimalism sells, especially in open source, because “I can write the 500 lines myself” means “I can read the 500 lines myself”, or “there’s only 500 lines of possible surprises in here”, or “how much of a security hole can 500 purposeful lines really hide?”

I began learning Phoenix two years ago and I’m still clueless on many of the niche specialized features - some that seem really crucial also! My blog ( https://operand.online ), has seen no progress in months because I’m scared to break things and know (from Rails) that the testing framework is one more complex piece to add to the burden as a solo coder.

Plus, I’m doing the programming research recommended by companies like https://inkandswitch.com and https://electric-sql.com/. Sync-based apps need more of an emphasis on the data layer, than the page-lifecycle or authorization logic. My money is riding on this new paradigm, so Phoenix begins to seem like “70,000 lines of code leading in a bad direction.”

A specific example? I think graph databases like Neo4j (https://community.neo4j.com/) are easier to design for than relational databases. I have ecto glued onto my blog for no reason - and I’d rather be shipping Boltx — Boltx v0.0.6 . In Nex, nothing holds me back. My form calls my function calls my Boltx connection and I can use the recordkeeping that makes sense in the moment.

Also, HTTP + SSL + websockets is incredibly complex in 2026, and merging the web spec into the elixir language so seamlessly earns my enormous respect. This is 500 lines of crucial glue that binds together the most scalable programming language to the most scalable publication medium. If you take the 500-line challenge, the inspiration from this codebase is going to be a superb guide. Who needs to sell? Open source asks for no price beyond your eagerness to learn and explore.

2 Likes

Using Phoenix for a blog seems pretty crazy to me. I’d recommend using a static site generator like Pelican or something if you want a “framework” for a blog. My blog is static HTML with tailwind and JS for the dynamic menus/links. The entire site is parsed by these three Python scripts:

#!/bin/python

"""Stitch together modular HTML files.

Currently, this script takes a single positional argument, which is
used as the project root directory.

Once implemented, it will take arguments for the following:
    - Project Root Directory (string/path)
    - HTML Source Directory (string/path)
    - Components Directory (string/path)
    - Output/Site Directory (string/path)
    - Flat/Nested Components Directory (bool/flag)
    - Automatically Include JS/CSS Files (bool/flag)

These arguments will be validated and documented by argparse, but for
now, they are not implemented yet.
"""

if __name__ != "__main__":
    raise ImportError("This is a standalone script and should not be imported.")

from pathlib import Path

# TODO: Replace this with argparse
import sys

project_root = Path(".")

try:
    project_root = Path(sys.argv[1])
except IndexError:
    pass


def getindent(line: str) -> str:
    """Return the indentation of `line` as a string of whitespace."""

    return line.split("<")[0]


components_dir = project_root / "src/components"


def parse_component_identifier(line: str) -> str:
    """Return the html of the web component that placeholder identifier
    `line` specifies with the same indentation level as the placeholder."""

    indentation = getindent(line)
    component_name = line.split(":")[1]
    component_prefix = components_dir / component_name
    component_filepath = component_prefix.with_suffix(".html")
    output_html: list[str] = []

    # Include component css in output.
    if component_prefix.with_suffix(".css").exists():
        style_url = (Path("/components") / component_name).with_suffix(".css")
        style_tag = f'<link rel="stylesheet" src="{style_url}">'
        output_html.append("".join([indentation, style_tag, "\n"]))

    # Include component typescript module in output.
    if component_prefix.with_suffix(".mts").exists():
        script_url = (Path("/components") / component_name).with_suffix(".mjs")
        script_tag = f'<script type="module" src="{script_url}"></script>'
        output_html.append("".join([indentation, script_tag, "\n"]))

    # Include component typescript script in output.
    elif component_prefix.with_suffix(".ts").exists():
        script_url = (Path("/components") / component_name).with_suffix(".js")
        script_tag = f'<script src="{script_url}"></script>'
        output_html.append("".join([indentation, script_tag, "\n"]))

    # Include component javascript module in output.
    elif component_prefix.with_suffix(".mjs").exists():
        script_url = (Path("/components") / component_name).with_suffix(".mjs")
        script_tag = f'<script type="module" src="{script_url}"></script>'
        output_html.append("".join([indentation, script_tag, "\n"]))

    # Include component javascript script in output.
    elif component_prefix.with_suffix(".js").exists():
        script_url = (Path("/components") / component_name).with_suffix(".js")
        script_tag = f'<script src="{script_url}"></script>'
        output_html.append("".join([indentation, script_tag, "\n"]))

    # Include component html in output.
    with open(component_filepath, "r") as html_file:
        for i in html_file.readlines():
            output_html.append("".join([indentation, i]))
    return "".join(output_html)


for root, dirs, files in (project_root / "src").walk(on_error=print):
    if "components" in dirs:
        dirs.remove("components")
    filepaths = [root / file for file in files if file.endswith(".html")]
    for fp in filepaths:
        str_dirpath = str(root).removeprefix(str(project_root)).removeprefix("/")
        print(str_dirpath)
        str_filepath = str(fp)
        output_dir = (
            project_root / "site" / str_dirpath.removeprefix("src").removeprefix("/")
        )
        output_path = output_dir / fp.name
        output_lines: list[str] = []
        with fp.open("r") as html_file:
            print(f"Parsing {str_filepath}...")
            for line in html_file.readlines():
                if line.lstrip().startswith("<!-- modulr-component"):
                    output_lines.append(parse_component_identifier(line))
                else:
                    output_lines.append(line)
        output_html = "".join(output_lines)
        if not output_dir.exists():
            output_dir.mkdir()
        print(f"Writing parsed {str_filepath} to {output_path}...")
        output_path.write_text(output_html)

and

#!/usr/bin/env python3.12


from pathlib import Path
import json
import sys

from bs4 import BeautifulSoup as BS

rootpath = Path("src")

try:
    rootpath = Path(sys.argv[1])
except IndexError:
    pass

output: list[str] = []

for root, dirs, files in rootpath.walk(on_error=print):
    if "components" in dirs:
        dirs.remove("components")
    filepaths = [root / file for file in files if file.endswith(".html")]
    for fp in filepaths:
        with open(fp, "r") as html_file:
            soup = BS(html_file, "html.parser")
            metadata = soup.find_all("meta")
            new_dict = {
                i.attrs["itemprop"]: i.attrs["content"]
                for i in metadata
                if "itemprop" in i.attrs and "content" in i.attrs
            }
            url = str(fp).removeprefix(str(rootpath))
            new_dict["url"] = url
            output.append(json.dumps(new_dict))
print("\n".join(output))

and

#!/usr/bin/env python3.12

import sys
import json

json_data = []

if sys.stdin.isatty():
    try:
        input_data = ",\n".join(sys.argv[1].splitlines(False))
        print(input_data)
        json_data = json.loads("\n".join(["[", input_data, "]"]))
    except IndexError:
        print("Error: no input provided")
        raise SystemExit
else:
    input_data = ",\n".join(sys.stdin.read().splitlines(False))
    json_data = json.loads("\n".join(["[", input_data, "]"]))

output_lines = [
    "export const ARTICLES = [\n",
]

for i in json_data:
    object_lines = json.dumps(i, indent=2).splitlines(False)
    for line in object_lines:
        new_line = line
        if line != "{" and not line.endswith(","):
            new_line = "".join([line, ",", "\n"])
        else:
            new_line = "".join([line, "\n"])
        output_lines.append("".join(["  ", new_line]))
output_lines.append("]")
print("".join(output_lines))

And the “CI”:

#!/bin/bash

python mkmeta.py | jq -c 'select(.category == \"article\")' | python mkpagedb.py > src/pagedb.mts
python modulr.py
tailwindcss -i ./src/tailwind.css -o ./site/main.css
tsc
cp -r ./assets/* ./site/

I originally put all this together in an afternoon a couple years ago. I had planned on cleaning all this up and making it not be a horrific mess, but I got distracted, and it’s never needed any changes cause static HTML Just Works :trade_mark:

I’d definitely recommend exploring bare-bones options for sites that don’t need an actual application framework. It’s pretty cool how much can actually be done with the basic building blocks of the web. :slight_smile:

1 Like

On the change to req.query and req.body .. I wonder if there’s a real reason to keep them separate?

Yes, from the HTTP perspective they are different. And yes, as a dev I can have variables of the same name in URI and in POST content and may want to keep those distinct (probably, I would assume, because I hate myself and enjoy creating the opportunity for subtle bugs :wink:

Would there be any downside to just having something like a req.input that combines ALL of what is in query+body into one set of data? Then I really don’t have to care about these things at all.

FWIW, I work with a set of APIs that allow one to make calls with variables in the URI with GET or in JSON documents via POST. (Not my decision, just something I get to work with :wink: ), and that sort of design would be so much easier to implement if the distinction between query and body were elided.

I assume there must be some benefit to keeping them separated like next.js and nex do?

I probably used the wrong word by saying “sell”. It is more appropriate to use “evangelize” in this context. A framework is a realization of the creator’s mental model, but it’s not everyone’s mental model. So, if the creator wants a broader adoption than whoever think exactly like himself, some code bloat is inevitable. I am sure Phoenix started pretty compact.

1 Like

This is true if you design in a vacuum, but in reality that does not happen.

For example, if I were designing a framework (which I am probably) I would start with something like React. In doing so I would inherit design decisions that were made over 10-15 years of real-world use, incorporating the experiences of millions of devs and billions of users. The same would be true if you started with a Rails-like as Phoenix did.

Even if you try new things you are still building upon the designs of those who came before you, likely without even thinking about it, and in doing so you actually are taking into account the mental models of others.

At the end of the day “minimalism” can mean different things. It could mean spending the huge amount of effort needed to come up with a minimal but expressive design that meets some broad requirements. You could just call that “good engineering”.

On the other hand, it could mean “a design that meets only my specific requirements and nobody else’s”. I think this is what you have in mind, and I agree it’s not a great mentality for framework design. Even if you design only for yourself your own requirements will eventually grow and you may have designed yourself into a corner.

1 Like

I’m not sure either of the meanings you mention quite fit the broadly accepted definition, which also seems applicable here, but I think it raises some interesting questions.

It certainly can’t mean just “specific requirements,” as very specific use cases can have a large number of potential requirements and any approach that tries to solve all of them couldn’t be described as “minimalist” just because few other people happen to share all those requirements. But I also don’t think it’s the same as good engineering. You can design a car with a built in ice cream maker–obviously not minimalist, probably not practical, but it can be really well engineered or not. Rather minimalism in any context means actively, intentionally, reducing elements in any system: fewer pieces of furniture in your house, lines and shades in your painting, plants in your garden, or features in your software. “Less is more” etc

Now, I will grant that it is true that in the industry clients, generally speaking, want more features so programmers find themselves in the position of advocating for less. Even in private, or open source projects, developers might strive for simplicity (which I think your first meaning gets at). But this is not because most programmers insist on following minimalist principles, but rather because the requested features won’t actually solve the problem, or the problem is already solved, or in some cases the problem might even be imagined. I think this is different than minimalism, because minimalism requires not solving problems. Which is why I agree strongly with @derek-zhou that it does not sell, even when it’s done really well. In fact, it sells so poorly that clients often complain about simplicity precisely because they mistake it for minimalism.

As an example, imagine someone sets out to organize their kitchen on minimalist principles. This probably means they are only going to have 1 or 2 types of dishes and glasses. What happens if they want to host a large party? Well, they can’t. Or rather, their kitchen can’t. They would need to host somewhere else or (at least temporarily) change their kitchen. In contrast, “simplifying” a kitchen might mean only having 1 type of water glass, instead of 3 or 4, because the extra styles of glass aren’t actually addressing any need. I think the distinction is significant and not just semantics because even minimalist designs can fail to be simple. If you get rid of 98% of your kitchen but still have 2 salad spinners, your design is probably not fully simplified. Conversely, “maximalist” approaches can be unnecessarily complex insofar as there is a difference, even a tension, between trying to add as much as possible and adding the wrong things. Your extra water glasses might mean that you can’t stock a fondue set.

1 Like

I actually think it is a great mentality to design a framework. Your framework has to serve someone, and that someone would have to be yourself in the beginning. (Dogfooding). My point was: evangelizing should come after you have dogfood’ed yourself for a few diverse projects, so you can prove that you didn’t design yourself into a corner, or you have rescued yourself from the original corner. At that stage, you most likely cannot claim the doctrine of minimalism anymore.

3 Likes

I am definitely not opposed to designing frameworks for yourself (I mean, obviously, lol).

I’m just suggesting that in practice when you design something it’s going to be based on prior art. This is true of everything. “Great artists steal” and all that.

I agree with you about evangelism; obviously a framework should not be taken seriously if it has not been proven!

2 Likes

I wasn’t trying to provide a definition of minimalism, but rather to suggest that I’m not entirely sure there is a broadly-accepted definition. I think your reply (which I find quite agreeable) is kinda case in point there.

Honestly, I have no idea what minimalism is. Like, people often referred to Steve Jobs’s Apple as “minimalist” and said he was a minimalist. But Apple made computers and software, the most complicated thing humanity has ever produced. A general tool for arbitrary computation! This is, like, the least minimalist thing ever conceived. And if you’ve ever seen pictures of his house you know he was not a minimalist there either lol.

I see “minimalist” used to describe well-thought-out tools, and I also see it used to describe low-effort garbage. Both of these meanings are common.

I’m not convinced it actually means anything tbh. Maybe I am just a UNIX hater.

With respect to different interpretations of minimalism, I think it’s often similar to how devs talk about “simplicity”. Great talk by Rich Hickey on the subject of “ simple” vs “easy”:

I really like how he defines complexity in terms of syntax overloading. I think expanding this to concept overloading is reasonable, so the changes in the OP around merging the different usable modules is a good example of “simplifying” by reducing conceptual surface area I think. :slight_smile:

1 Like

Maybe I didn’t sufficiently highlight the definition I think is broadly accepted:

I’m not sure the claim that it’s broadly accepted is really that debatable tbh, but is there a reason it doesn’t seem clear/useful?

2 Likes

I think it’s useful, but I’ve definitely seen uses of the term in the wild that do not match that definition. But there is no point in debating such a thing as you say.

1 Like

Absolutely true, classic example is the guy with nothing but a lawn chair, a tv and a mattress in his studio apartment and calls that “minimalism” when it’s really just nihilism. There are almost certainly programming equivalents there…

And just to be clear, I am not advocating for minimalism should be a privileged concept (the opposite really), but rather that even when used correctly it’s not a good metric for simplicity. The talk @GrammAcc linked above makes a similar point but I was put off by his sniping at agile (which is not even an approach to software architecture at all but the development process but that’s a whole other axe I’ll avoid grinding here). But the same basic point applies: we all should strive for simplicity but avoid confusing that with other more straightforward traits like ease, convenience, or minimalism, even if those other things can also have their place.

2 Likes

I’d say minimalism is a necessary step in the life of a piece software. It is like virginity, you hold on to it when it makes sense to you and abandon it when it does not help you anymore.