<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kajetan Tukendorf</title>
    <description>The latest articles on DEV Community by Kajetan Tukendorf (@kajetk).</description>
    <link>https://dev.to/kajetk</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3921961%2Fa3e1476e-656a-4866-b805-1ee149c79712.png</url>
      <title>DEV Community: Kajetan Tukendorf</title>
      <link>https://dev.to/kajetk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kajetk"/>
    <language>en</language>
    <item>
      <title>How I turned a Python function into a web app in one decorator</title>
      <dc:creator>Kajetan Tukendorf</dc:creator>
      <pubDate>Sat, 23 May 2026 19:06:47 +0000</pubDate>
      <link>https://dev.to/kajetk/how-i-turned-a-python-function-into-a-web-app-in-one-decorator-2oab</link>
      <guid>https://dev.to/kajetk/how-i-turned-a-python-function-into-a-web-app-in-one-decorator-2oab</guid>
      <description>&lt;p&gt;I've been building small utility tools for the web; JSON formatters, CSV processors, PDF text extractors, those kinds of things. The kind of tools where you have a problem, write a 50-line Python function to solve it, and it &lt;em&gt;just works&lt;/em&gt;. Then, you want to share it with someone, but they can't be bothered (or don't know how) to set up the script and run it locally.&lt;/p&gt;

&lt;p&gt;The obvious solution is "build a web app". The obvious problem is that building a frontend for every small script is a lot of overhead for something that should take an afternoon.&lt;/p&gt;

&lt;p&gt;So I built something a little different.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: a Python function decorated with @tool() automatically becomes a web app. No React. No API endpoint. No routing.&lt;/p&gt;

&lt;p&gt;Here's what a tool looks like on the Python side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;nix_sdk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TextResult&lt;/span&gt;

&lt;span class="nd"&gt;@tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Simple JSON tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Validate a JSON file - paste or upload!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json-tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;json_formatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;json_input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JSON input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;placeholder&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Paste JSON here...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Dropdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Indent width&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Switch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sort keys alphabetically&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TextResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Format and validate a JSON document.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json_input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;TextResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The @tool() decorator inspects the function's type hints and default values, and auto-generates a JSON Schema manifest. That manifest looks something like this:&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "name": "Simple JSON tool",&lt;br&gt;
  "slug": "json-tool",&lt;br&gt;
  "input_schema": {&lt;br&gt;
    "type": "object",&lt;br&gt;
    "properties": {&lt;br&gt;
      "json_input": {&lt;br&gt;
        "type": "string",&lt;br&gt;
        "x-nix": { "widget": "textarea", "label": "JSON input" }&lt;br&gt;
      },&lt;br&gt;
      "indent": {&lt;br&gt;
        "type": "integer",&lt;br&gt;
        "enum": [2, 4, 8],&lt;br&gt;
        "x-nix": { "widget": "dropdown", "label": "Indent width" }&lt;br&gt;
      },&lt;br&gt;
      "sort_keys": {&lt;br&gt;
        "type": "boolean",&lt;br&gt;
        "x-nix": { "widget": "switch", "label": "Sort keys alphabetically" }&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  },&lt;br&gt;
  "output": { "type": "text" }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Separately, the Nix frontend shell reads that manifest and renders the form. Completely automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the shell does
&lt;/h2&gt;

&lt;p&gt;The Next.js frontend has one dynamic route: &lt;code&gt;/tools/[slug]&lt;/code&gt;. It fetches the manifest for that slug from the FastAPI backend, then renders the form using a custom renderer registry. Each &lt;code&gt;x-nix.widget&lt;/code&gt; type maps to a &lt;a href="https://mantine.dev/" rel="noopener noreferrer"&gt;Mantine&lt;/a&gt; component — &lt;code&gt;textarea&lt;/code&gt; becomes a &lt;code&gt;Textarea&lt;/code&gt;, &lt;code&gt;dropdown&lt;/code&gt; becomes a &lt;code&gt;Select&lt;/code&gt;, &lt;code&gt;switch&lt;/code&gt; becomes a &lt;code&gt;Switch&lt;/code&gt;, &lt;code&gt;file&lt;/code&gt; becomes a file upload zone.&lt;/p&gt;

&lt;p&gt;The same shell also renders a static landing page at /tools/[slug] with optional seo fields for metadata, H1, and FAQ content so that the content can be posted publicly. One Python file produces both the tool interface and the SEO-optimised landing page.&lt;/p&gt;

&lt;p&gt;No React by you. No routing by you. No API wiring by you.&lt;/p&gt;

&lt;h2&gt;
  
  
  How execution works
&lt;/h2&gt;

&lt;p&gt;The frontend submits the form to POST /api/tools/{slug}/run. FastAPI looks up the tool by slug, validates the input against the JSON Schema, and calls the Python function in a thread via &lt;code&gt;asyncio.to_thread()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For long-running tools, there's a WebSocket path at &lt;code&gt;/ws/tools/{slug}/run&lt;/code&gt;. The tool can call &lt;code&gt;progress("Step 2 of 4: processing rows...")&lt;/code&gt; from inside the function and those messages stream to the UI in real time.&lt;/p&gt;

&lt;p&gt;File uploads are handled as multipart form data. Files are ephemeral - held in temp storage during execution, then discarded. Nothing is persisted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's live now
&lt;/h2&gt;

&lt;p&gt;I've used this to build nine tools that are live at &lt;a href="https://nix.tech" rel="noopener noreferrer"&gt;nix.tech&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON Formatter &amp;amp; Validator&lt;/strong&gt; and &lt;strong&gt;XML Formatter &amp;amp; Validator&lt;/strong&gt;; validates with exact line/column error locations, finds all issues at once rather than stopping at the first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF Text Extractor&lt;/strong&gt;; page-range selection, explicit scanned-PDF detection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSV to JSON Converter&lt;/strong&gt;; handles TSV too&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSV Deduplicator&lt;/strong&gt;; column-level dedup with keep-first/keep-last options&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Markdown to HTML&lt;/strong&gt;; GFM extensions, fragment or full page output&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base64 Encoder/Decoder&lt;/strong&gt;; handles files, not just strings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO Keyword Research&lt;/strong&gt;; uses DataForSEO's API (to see how it works in these tools!) and provides search volume, CPC and ad competition without a Google Ads account&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alt Text Generator&lt;/strong&gt;; AI-generated, SEO-focused alt text with optional site context&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All the tools have free usage limits, without sign-ups. Files discarded after processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tradeoffs
&lt;/h2&gt;

&lt;p&gt;The manifest-driven approach has obvious limits. Complex multi-step flows are hard to express in a flat JSON Schema. Conditional field visibility requires extensions to the spec. Rich output types (e.g., interactive charts, side-by-side diffs) need custom renderer work.&lt;/p&gt;

&lt;p&gt;For single-function utility tools - which is where I was testing for now — none of those limits are an issue. The upside is that adding a new tool is adding one Python file. The frontend, API endpoint, landing page, and form all derive from the manifest automatically.&lt;/p&gt;

&lt;p&gt;Whether that tradeoff stays favourable at scale is a different question.&lt;/p&gt;

&lt;p&gt;If any of the architecture is interesting to dig into further, happy to go into more detail in the comments!&lt;/p&gt;

</description>
      <category>python</category>
      <category>showdev</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
