The .pyxl file format
A Pyxle page is a single file with two languages in it: Python and JSX. This doc explains why that file exists, what's actually in it, and what mental model the rest of the framework uses for it.
If you want to know how Pyxle splits the two languages, that's the next doc — The parser.
Why one file?
Web pages are the unit of work for most web developers. "I'm working on the checkout page. I'm fixing the 404. I'm adding a settings page." The page is the noun.
In most frameworks, that one mental noun is spread across at least two files: a backend handler in one place and a UI component in another. That separation has merits — different languages, different runtimes, different deployment surfaces — but it also has a real cost. Every non-trivial change has to keep the two files in sync.
Pyxle's bet is that colocating both halves of a page in one file is worth it, even at the cost of inventing a new file extension. When you change the loader, the component is right there. When you rename a prop, both sides update in the same diff. When you delete a page, you delete one file.
This isn't a unique idea — Vue's .vue files, Svelte's .svelte files,
Astro's .astro files, MDX's .mdx files all do something similar. What's
different about .pyxl is that the two languages inside are the full
ones: real Python (with import, class, async def, decorators,
asyncio, the works) and real JSX (with import React, hooks, ES module
syntax, the works). Neither is a stripped-down DSL.
What a .pyxl file looks like
Let's start with the simplest one and grow it.
A pure-JSX page
# pages/about.pyxl
import React from 'react';
export default function About() {
return (
<main className="p-8">
<h1>About</h1>
<p>This page has no Python at all.</p>
</main>
);
}This is valid. Some pages just don't need a loader. Pyxle is fine with
that — the parser sees zero Python content, the compiled .py file is
empty, the route is registered but has no @server function.
Pyxle in Next.js terms: This is the equivalent of a Next.js
.tsxpage that doesn't exportgetServerSideProps. It's a static component with no server-side logic.
A page with a server loader
# pages/status.pyxl
import time
@server
async def load(request):
return {"now": time.time(), "version": "0.1.7"}
import React from 'react';
export default function Status({ data }) {
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}The Python comes first, then the JSX, in that order. The two halves are separated only by whitespace and the natural transition from Python statements to ES module syntax. The parser will figure it out.
A few things to notice in this example:
@serveris a decorator with no parentheses. It's not a factory function or a wrapper — it's a pure tag. See The runtime.- The loader is async. Always. The parser refuses to compile a sync one.
- The loader takes a single argument named
request. This is also enforced by the parser. - The return value is a plain dict. It will be serialized to JSON
and passed to the React component as a prop named
data. - The JSX block looks like a regular React file.
import React from 'react', anexport default function, JSX syntax. There's nothing Pyxle-specific in the JSX section. You could copy-paste it into a standalone.jsxfile and it would work.
A multi-section page
Here's a more interesting pattern: alternating Python and JSX blocks.
# pages/dashboard.pyxl
from datetime import datetime
# A Python helper used by the loader.
def format_time(dt):
return dt.strftime("%Y-%m-%d %H:%M:%S")
import React from 'react';
// A JSX helper used by the page component.
function StatCard({ label, value }) {
return (
<div className="stat-card">
<strong>{label}</strong>
<span>{value}</span>
</div>
);
}
@server
async def load_dashboard(request):
return {
"now": format_time(datetime.now()),
"user_count": 142,
}
@action
async def refresh(request):
return {"refreshed_at": format_time(datetime.now())}
import { useAction } from 'pyxle/client';
export default function Dashboard({ data }) {
const refresh = useAction('refresh');
return (
<main>
<h1>Dashboard</h1>
<StatCard label="Now" value={data.now} />
<StatCard label="Users" value={data.user_count} />
<button onClick={() => refresh()}>Refresh</button>
</main>
);
}This one has four alternating sections: Python helper → JSX helper → Python loader+action → JSX export. The parser handles arbitrary alternation. There's no fence marker, no separator comment, no special syntax. The boundaries are detected by parsing — see The parser.
Aside: Pyxle used to require explicit
# --- client ---and# --- server ---markers to split sections. We removed them in v0.1.7 because the AST-driven walker is reliable enough that markers became noise. Old files with markers still parse correctly because the markers are valid Python comments — they just no longer mean anything.
A page with document <head> metadata
Use the <Head> component from pyxle/client to control what ends up in
the document head. It's the recommended approach for nearly every page:
# pages/blog/[slug].pyxl
@server
async def load_post(request):
slug = request.path_params["slug"]
post = await fetch_post(slug)
return {"post": post}
import React from 'react';
import { Head } from 'pyxle/client';
export default function Post({ data }) {
return (
<article>
<Head>
<title>{data.post.title} — My Blog</title>
<meta name="description" content={data.post.excerpt} />
</Head>
<h1>{data.post.title}</h1>
<div dangerouslySetInnerHTML={{__html: data.post.html}} />
</article>
);
}Pyxle's JSX compiler recognises <Head> blocks during parsing, extracts
them as metadata, and during SSR the head merger hoists them into the
document <head> alongside contributions from parent layouts. Dynamic
content interpolates through normal JSX expressions
({data.post.title}), and nested components can contribute their own
head elements — see SSR § Stage 3: resolving the head.
For the rare case where a page has no JSX component at all and still
needs head metadata, Pyxle also supports a compile-time HEAD Python
variable:
HEAD = '<title>Blog post</title><meta name="description" content="A blog post" />'The parser extracts it from the AST at compile time and stores it in
the page metadata. It supports strings, lists of strings, or a callable
def HEAD(data): ... that receives the loader's return value. For most
real pages, reach for <Head> instead — the JSX form is easier to
write, easier to type-check, and composes naturally with layouts.
What's *not* in a .pyxl file
A .pyxl file is always one route — one page. It is not:
- A library file. If you have shared Python utilities, put them in a
regular
.pyfile andimportthem from your.pyxl. The parser does not look inside.pyfiles. - A reusable component file. If you have shared JSX components, put
them in a regular
.jsxfile andimportthem from your.pyxl. The bundler resolves these like any normal module import. - An API handler. Plain JSON APIs go in
pages/api/*.pyfiles (regular Python, no JSX). See Routing § API routes. - A layout or template. Layouts use the same
.pyxlextension but live inlayout.pyxlfiles at strategic points in the directory tree. They wrap pages instead of being pages themselves. See Routing § Layouts.
What the framework "sees"
When the compiler processes your .pyxl file, it produces a single
PyxParseResult object that captures everything the rest of the
framework needs. The shape is:
@dataclass(frozen=True)
class PyxParseResult:
python_code: str # The Python half
jsx_code: str # The JSX half
loader: LoaderDetails | None # @server function info
actions: tuple[ActionDetails, ...] # @action function info
head_elements: tuple[str, ...] # static HEAD content
head_is_dynamic: bool # HEAD is a callable?
head_jsx_blocks: tuple[str, ...] # <Head> JSX blocks
script_declarations: tuple[dict, ...] # <Script> components
image_declarations: tuple[dict, ...] # <Image> components
python_line_numbers: Sequence[int] # for error mapping
jsx_line_numbers: Sequence[int] # for error mapping
diagnostics: tuple[PyxDiagnostic, ...] # in tolerant modeSource: compiler/parser.py:83.
A few things worth understanding here:
python_codeandjsx_codeare strings, not ASTs. The parser does parse them withast.parseto find segment boundaries and extract metadata, but the output is just two strings ready to be written to disk as the compiled.pyand.jsxfiles.python_line_numbersandjsx_line_numbersare line maps. When the compiler finds an error in the joined Python output (say, line 7 ofpython_code), it can look uppython_line_numbers[6]to find the original line number in the source.pyxlfile. This is how Pyxle's error messages always point at the right line.loader,actions,head_elements,head_jsx_blocks,script_declarations, andimage_declarationsare extracted at parse time. The dev server doesn't have to re-parse anything to answer questions like "does this page have a loader?" or "what<Script>tags should be injected before hydration?". The metadata is precomputed once and read at request time.diagnosticsis populated only in tolerant mode. Whenpyxle checkruns the parser, it passestolerant=True. Errors that would normally raiseCompilationErrorare collected asPyxDiagnosticentries instead, so a single check pass can report every error in every file at once.
File extension and tooling
Pyxle uses .pyxl because:
- It signals "this isn't a normal Python file" to anyone glancing at the directory.
- Most editors don't have Pyxle support yet (LSP support is on the
roadmap), so the convention is: set your editor to treat
.pyxlas Python for syntax highlighting. The Python half lights up correctly; the JSX half ends up looking like a string of nonsense, which is honest about the situation. - The Cython project uses
.pyxlfor a different purpose. We know. Pyxle is for web pages, Cython is for compiled Python extensions — in practice they don't overlap, and the Pyxle parser has nothing to do with the Cython parser.
When the Pyxle LSP ships (separate package, pyxle-langkit), editors
will get correct dual-language highlighting, jump-to-definition for
both halves, and inline diagnostics. Until then, the convention is
"highlight as Python and squint at the JSX".
What if I'm coming from another framework?
| You know... | And you're wondering... | Answer |
|---|---|---|
| Next.js | Where's getServerSideProps? |
It's the @server decorator on a function called whatever you want. |
| Next.js | Where's app/page.tsx and route.ts? |
One file: the loader and the component live together in pages/foo.pyxl. |
| Remix | Where's loader and action? |
@server for loaders, @action for actions. Same idea, different name. |
| Django | Where's views.py? |
The loader function is the view. The template is the JSX in the same file. |
| FastAPI | Where's the route decorator? | Pyxle uses file-based routing — no @app.get("/"). The file path is the route. |
| Rails | Where's the controller? | The loader is the controller. The action is the controller's mutation handler. |
| Flask | Where's @app.route? |
Same as FastAPI: file-based. |
| SvelteKit | Where's +page.server.ts? |
One file, not two. The Python half is +page.server.ts. |
The recurring theme: Pyxle compresses what other frameworks split into multiple files into a single file per route, while keeping the two halves in their original languages.
Where to read next
You now know what a .pyxl file is, what's in it, and what the parser
produces from it. The next question is: how does the parser actually
split Python from JSX without any markers?
That's an interesting algorithm with a few subtle correctness traps, and it gets its own deep-dive doc:
→ The parser.