Tutorial
Build your first Jeasx app
In this tutorial you'll build a small reading list app from scratch. Step by step, you'll learn the essential features of Jeasx: routes, layouts, async components, streaming, guards, forms, JSON endpoints and browser assets.
The finished app has:
- a home page which lists your books and can filter them by reading status,
- a detail page for each book, enriched with live data from the Open Library API,
- a JSON API which exposes your books to other apps,
- a password-protected admin area to add books, change their status and delete them,
- a friendly 404 page, a bit of CSS and a pinch of JavaScript.
All you need is a recent Node.js (latest LTS) and an editor of your choice. Plan about 30 minutes.
Want to skip ahead or compare your code? See the finished app in action in the live preview. Its source code is available on GitHub: jeasx/jeasx-tutorial. Clone it with git clone https://github.com/jeasx/jeasx-tutorial.git, then run npm install and npm run dev.
Step 1: Create the project
Create a new project from the quickstart template and start the dev server:
npm create jeasx@latest reading-list
cd reading-list
npm run dev
Open http://localhost:3000/ to see the example app. Keep the dev server running for the whole tutorial. Jeasx rebuilds your code on every change, so you just have to reload your browser.
The template contains example pages which you won't need. Delete them in a second terminal:
rm -r "src/[index].jsx" "src/[...path].jsx" src/components src/browser
Your terminal will show some build errors now, because src/[404].jsx imports a layout which no longer exists. Don't worry, you'll fix this in the next step.
Here is what the project will look like at the end of this tutorial:
reading-list
├── .env # Environment variables (password, cookie secret)
├── jeasx.config.js # Configuration for esbuild & Fastify
└── src
├── [...guard].js # Runs before every route
├── [index].jsx # Home page: /
├── [404].jsx # Not found page
├── [health].json # Health check: /health
├── admin
│ ├── [...guard].js # Protects everything below /admin
│ ├── [index].jsx # Admin page: /admin
│ ├── [login].jsx # Login page: /admin/login
│ └── [logout].js # Logout: /admin/logout
├── api
│ └── [books.json].js # JSON API: /api/books.json
├── books
│ └── [...path].jsx # Detail pages: /books/1, /books/2, ...
├── browser
│ ├── index.css # Stylesheet: /index.css
│ └── index.js # Script: /index.js
├── components
│ └── Layout.jsx # Shared page layout
└── lib
└── books.js # Data access (not a route)
The most important rule is already visible here: filenames in brackets are routes. Everything else (components, libraries, ...) is just code you can import. Learn more about routes.
Step 2: Create a layout and use the context
Every page needs the same HTML skeleton. Create a layout component at src/components/Layout.jsx:
import { html } from "jsx-async-runtime";
/**
* @param {{ title: string, children?: JSX.Element }} props
*/
export default function Layout({ title, children }) {
return (
<>
{html("<!DOCTYPE html>")}
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} | Reading List</title>
<link rel="stylesheet" href={`/index.css?v=${process.env.BUILD_ID}`} />
<script type="module" src={`/index.js?v=${process.env.BUILD_ID}`}></script>
</head>
<body>
<header>
<a href="/" class="logo">
📚 Reading List
</a>
<nav>
<NavLink href="/">Books</NavLink>
<NavLink href="/admin">Admin</NavLink>
</nav>
</header>
<main>{children}</main>
</body>
</html>
</>
);
}
/**
* @this {import("../types").ThisContext}
* @param {{ href: string, children: JSX.Element }} props
*/
function NavLink({ href, children }) {
const active = this.request.path === href;
return (
<a href={href} aria-current={active ? "page" : undefined}>
{children}
</a>
);
}
A few things are different from React:
- Attributes use their HTML names, e.g.
classinstead ofclassName. - All text is escaped. To output trusted markup like the doctype as is, wrap it with
html(). process.env.BUILD_IDis defined injeasx.config.jsat build time. It's appended to the asset URLs, so browsers load the new files after each deployment.
The NavLink component needs the current path to highlight the active link. Instead of passing the request down through all components, it reads the request from this. Jeasx passes the same this context to every route and component that renders a request. Which raises the question: who put the request there?
Have a look at src/[...guard].js from the template:
/**
* @param {import("./types").RouteProps} props
*/
export default function ({ request, reply }) {
// Guards run before every route in this directory and below.
// Provide request/reply via 'this' in components (e.g. Layout.jsx).
this.request = request;
this.reply = reply;
}
A guard runs before all routes in its folder and below. Because this guard lives in src, it runs for every request, so this.request is available everywhere. Every route and guard gets the Fastify request and reply objects as props.
Step 3: Store your books
To keep things simple, the books are stored in memory. Create a small module to manage them at src/lib/books.js:
/**
* @typedef {{ id: number, title: string, author: string, isbn: string, status: string }} Book
*/
export const STATUS = {
"to-read": "To read",
reading: "Reading",
finished: "Finished",
};
// Each route is bundled separately, so a module variable would exist once per route.
// globalThis is shared by all routes and even survives hot reloads during development.
const store = /** @type {{ books?: Book[] }} */ (globalThis);
store.books ??= [
{
id: 1,
title: "The Pragmatic Programmer",
author: "David Thomas, Andrew Hunt",
isbn: "9780135957059",
status: "finished",
},
{
id: 2,
title: "Refactoring",
author: "Martin Fowler",
isbn: "9780134757599",
status: "reading",
},
{
id: 3,
title: "Designing Data-Intensive Applications",
author: "Martin Kleppmann",
isbn: "9781449373320",
status: "to-read",
},
];
export function getBooks() {
return store.books;
}
/**
* @param {number} id
*/
export function getBook(id) {
return store.books.find((book) => book.id === id);
}
/**
* @param {Omit<Book, "id">} book
*/
export function addBook(book) {
const id = Math.max(0, ...store.books.map((book) => book.id)) + 1;
store.books.push({ id, ...book });
return id;
}
/**
* @param {number} id
*/
export function deleteBook(id) {
store.books = store.books.filter((book) => book.id !== id);
}
/**
* @param {number} id
* @param {string} status
*/
export function updateStatus(id, status) {
const book = getBook(id);
if (book && Object.hasOwn(STATUS, status)) {
book.status = status;
}
}
As the filename has no brackets, this module is not exposed as a route. It's just a regular module you can import anywhere.
Jeasx bundles each route into its own file. A module imported by two routes is therefore loaded
twice, and each route would see its own copy of a variable. globalThis is shared by all routes,
so it's the place for shared objects like caches, connection pools or, as here, a small in-memory
store. As a bonus, it survives hot reloads during development. For a real app, you'd store the
books in a database instead, so they survive a restart of the server.
Step 4: Build the home page
src/[index].jsx answers requests for /. Like any route, it is a function which returns JSX:
import Layout from "./components/Layout";
import { getBooks, STATUS } from "./lib/books";
/**
* @param {import("./types").RouteProps} props
*/
export default function ({ request }) {
const status = request.query["status"];
const books = getBooks().filter((book) => !status || book.status === status);
return (
<Layout title="My books">
<h1>My books</h1>
<p class="filter">
<a href="/" aria-current={!status ? "page" : undefined}>
All
</a>
{Object.entries(STATUS).map(([key, label]) => (
<a href={`/?status=${key}`} aria-current={status === key ? "page" : undefined}>
{label}
</a>
))}
</p>
{books.length === 0 ? (
<p>No books found.</p>
) : (
<ul class="books">
{books.map((book) => (
<li>
<a href={`/books/${book.id}`} style={`view-transition-name: book-${book.id}`}>
{book.title}
</a>{" "}
by {book.author} <span class={`badge ${book.status}`}>{STATUS[book.status]}</span>
</li>
))}
</ul>
)}
</Layout>
);
}
Open http://localhost:3000/ and click the filters. Some things to notice:
- Routes are plain functions which receive the request and return JSX. If you need to load data asynchronously, simply make the function
asyncandawaitthe data. - Query parameters are available via
request.query, e.g./?status=reading. - There is no client-side JavaScript involved. Jeasx renders plain HTML on the server, so the page is fast and works everywhere.
Step 5: Show book details with a dynamic route
Every book needs its own page at /books/1, /books/2 and so on. Instead of creating a file for each book, create a dynamic route at src/books/[...path].jsx. It answers all requests below /books:
import Layout from "../components/Layout";
import { getBook, STATUS } from "../lib/books";
/**
* @param {import("../types").RouteProps} props
*/
export default function ({ request }) {
// "/books/2" => 2
const id = Number(request.path.split("/").at(-1));
const book = getBook(id);
// Return nothing, so the request is passed on to the [404] route.
if (!book) {
return;
}
return (
<Layout title={book.title}>
<a href="/">« All books</a>
<article class="book">
<div>
<h1 style={`view-transition-name: book-${book.id}`}>{book.title}</h1>
<p>
by {book.author} <span class={`badge ${book.status}`}>{STATUS[book.status]}</span>
</p>
<BookDetails isbn={book.isbn} />
</div>
<img src={`https://covers.openlibrary.org/b/isbn/${encodeURIComponent(book.isbn)}-M.jpg`} alt="" width="180" class="cover" />
</article>
</Layout>
);
}
/**
* @param {{ isbn: string }} props
*/
async function BookDetails({ isbn }) {
try {
const response = await fetch(
`https://openlibrary.org/search.json?isbn=${encodeURIComponent(isbn)}&fields=first_publish_year,number_of_pages_median,subject&limit=1`,
);
const [details] = (await response.json()).docs;
return (
<dl>
<dt>First published</dt>
<dd>{details.first_publish_year}</dd>
<dt>Pages</dt>
<dd>{details.number_of_pages_median}</dd>
<dt>Subjects</dt>
<dd>{details.subject?.slice(0, 5).join(", ")}</dd>
</dl>
);
} catch {
return <p>No details available.</p>;
}
}
Click on a book on the home page to see the result. Two essential features are at work here:
Return nothing to pass the request on. If a book doesn't exist (e.g. /books/99), the route returns undefined. Jeasx then tries the next matching route, which ends at the 404 page you'll create in the next step.
Async components and streaming. BookDetails is an async component which fetches data from Open Library. You don't need any hooks or loaders. Just use it like any other component. Jeasx streams the page to the browser while it renders, so the header and title arrive immediately, while the details follow as soon as the API answers.
Because the page has already been sent in part, an error in BookDetails can't turn into a nice error page anymore. That's why the component catches its own errors and shows a fallback. Learn more about this in the FAQ.
Step 6: Add a 404 page
Replace src/[404].jsx with a version using your new layout:
import Layout from "./components/Layout";
/**
* @param {import("./types").RouteProps} props
*/
export default function ({ request }) {
return (
<Layout title="Not found">
<h1>Not found</h1>
<p>Sorry, there is nothing at {request.path}.</p>
<a href="/">Back to my books</a>
</Layout>
);
}
Now visit http://localhost:3000/books/99 or any other unknown URL. Jeasx sets the status code 404 for you. You can also put a [404] route into a subfolder to show a different page for a section of your site.
Step 7: Provide a JSON API
Other apps may want to use your reading list, too. Create src/api/[books.json].js:
import { getBooks } from "../lib/books";
/**
* @param {import("../types").RouteProps} props
*/
export default function ({ reply }) {
reply.header("Access-Control-Allow-Origin", "*");
return getBooks();
}
When a route returns an object or an array instead of JSX, Jeasx sends it as JSON. Use the reply object to set headers, status codes and more. As the name of the route ends with .json, the URL does too:
curl http://localhost:3000/api/books.json
The [health].json route from the template shows an even simpler way: a JSON file with brackets is served as is at /health.
Step 8: Add a protected admin area
Now let's add a page to manage your books. Only you should be able to use it, so it's protected by a login form. Once you've logged in, a cookie remembers you.
Install a Fastify plugin
Jeasx keeps its core lean. Cookies are handled by the official @fastify/cookie plugin, so install it first:
npm install @fastify/cookie
Then register the plugin in jeasx.config.js. The template already registers a parser for HTML forms there, which makes submitted forms available via request.body. Just add the plugin to the chain:
FASTIFY_SERVER: (fastify) =>
// Required only if you want to process form submissions via POST requests.
fastify
.addContentTypeParser(
"application/x-www-form-urlencoded",
{ parseAs: "string" },
async (_request, body) => querystring.parse(body),
)
// Read and write cookies, signed with a secret (see `src/admin`).
.register(import("@fastify/cookie"), { secret: process.env.COOKIE_SECRET }),
To get autocompletion for the new cookie functions of request and reply in your editor, add the following import to src/types.d.ts:
import "@fastify/cookie";
Define secrets in .env
Passwords and secrets don't belong in your code. Create a .env file in the project root:
ADMIN_PASSWORD=secret
COOKIE_SECRET=replace-me-with-a-long-random-string
The cookie will be signed with COOKIE_SECRET, so nobody can fake it. Please use a long random string here, e.g. created with openssl rand -hex 32.
Jeasx loads .env files automatically into process.env, but only when the server starts. The same applies to jeasx.config.js. So restart the dev server now: stop it with Ctrl+C and run npm run dev again. You can use different files for each environment, e.g. .env.production. See configuration for details.
Protect a folder with a guard
Create src/admin/[...guard].js. This guard runs before every route below /admin, so all admin pages are protected in one place:
/**
* @param {import("../types").RouteProps} props
*/
export default function ({ request, reply }) {
// The login page must be reachable without being logged in.
if (request.path === "/admin/login") {
return;
}
// Only accept the cookie if its signature is valid, so nobody can fake it.
const { valid, value } = request.unsignCookie(request.cookies.user ?? "");
if (!valid) {
return reply.redirect("/admin/login", 303);
}
// Objects returned by a guard are passed as props to all routes below.
return { user: value };
}
A guard can do three things:
- Return nothing to let the request pass, like the guard in
src/[...guard].js. Here, it lets everybody see the login page. - Return a response (JSX, a string, ...) or send one via
replyto answer the request itself. No other route runs. Here, it redirects you to the login page if you are not logged in. - Return an object whose entries are passed as props to all routes below. Here, every admin route receives a
userprop.
Log in and log out
Create the login page at src/admin/[login].jsx. If the password is correct, it sets the signed cookie and redirects you to the admin page:
import { createHash, timingSafeEqual } from "node:crypto";
import Layout from "../components/Layout";
/**
* @param {import("../types").RouteProps} props
*/
export default function ({ request, reply }) {
let error = "";
if (request.method === "POST") {
const { password } = /** @type {Record<string, string>} */ (request.body ?? {});
if (isValidPassword(password)) {
reply.setCookie("user", "admin", {
path: "/admin",
signed: true, // Signed with COOKIE_SECRET, so it can't be faked.
httpOnly: true, // Not readable by JavaScript in the browser.
sameSite: "lax", // Not sent with forms posted from other sites.
secure: "auto", // Only sent via HTTPS, if the site uses HTTPS.
maxAge: 60 * 60 * 8, // Log out automatically after 8 hours.
});
return reply.redirect("/admin", 303);
}
reply.status(401);
error = "Wrong password, please try again.";
}
return (
<Layout title="Log in">
<h1>Log in</h1>
{error && <p class="error">{error}</p>}
<form method="post" class="card">
<label>
Password <input type="password" name="password" required autofocus />
</label>
<button class="primary">Log in</button>
</form>
</Layout>
);
}
/**
* Compare in constant time, and never accept a login if no password is configured.
* @param {unknown} password
*/
function isValidPassword(password) {
const expected = process.env.ADMIN_PASSWORD;
if (!expected || typeof password !== "string") {
return false;
}
// Hash both sides so they have equal length, as timingSafeEqual requires.
const hash = (/** @type {string} */ value) => createHash("sha256").update(value).digest();
return timingSafeEqual(hash(password), hash(expected));
}
isValidPassword compares the passwords in constant time with timingSafeEqual, so an attacker can't guess the password from how long the check takes. It also refuses every login if ADMIN_PASSWORD isn't set. Thanks to maxAge, the cookie expires after 8 hours, so you are logged out automatically.
Logging out is even simpler. Create src/admin/[logout].js, which removes the cookie and sends you back to the home page:
/**
* @param {import("../types").RouteProps} props
*/
export default function ({ request, reply }) {
// Only log out via the form, not by simply opening the URL.
if (request.method !== "POST") {
return;
}
reply.clearCookie("user", { path: "/admin" });
return reply.redirect("/", 303);
}
The route only answers POST requests, which are sent by the logout button you'll add in a moment. For all other requests, it returns nothing, so the request ends up at the 404 page.
Handle a form
Create the admin page at src/admin/[index].jsx. The same route shows the page (GET) and processes its forms (POST). A hidden action field tells the forms apart: add a book, change its status or delete it. The logout button at the top is a small form, too. It posts to /admin/logout via its action attribute.
import Layout from "../components/Layout";
import { addBook, deleteBook, getBooks, STATUS, updateStatus } from "../lib/books";
/**
* @param {import("../types").RouteProps & { user: string }} props
*/
export default function ({ request, reply, user }) {
if (request.method === "POST") {
const form = /** @type {Record<string, string>} */ (request.body ?? {});
if (form.action === "delete") {
deleteBook(Number(form.id));
} else if (form.action === "status") {
const id = Number(form.id);
updateStatus(id, form.status);
// Jump back to the updated book, so you can see the change.
return reply.redirect(`/admin?updated=${id}#book-${id}`, 303);
} else if (form.title?.trim() && form.author?.trim()) {
const id = addBook({
title: form.title.trim(),
author: form.author.trim(),
// Keep only characters that can appear in an ISBN.
isbn: form.isbn?.replace(/[^0-9X]/gi, "").toUpperCase() ?? "",
status: Object.hasOwn(STATUS, form.status) ? form.status : "to-read",
});
// Jump to the new book, so you can see it was added.
return reply.redirect(`/admin?added=${id}#book-${id}`, 303);
} else {
reply.status(400);
return <AdminPage user={user} error="Please enter a title and an author." form={form} />;
}
// Redirect after POST, so reloading the page doesn't submit the form again.
return reply.redirect("/admin", 303);
}
return (
<AdminPage
user={user}
updated={Number(request.query["updated"])}
added={Number(request.query["added"])}
/>
);
}
/**
* @param {{ user: string, error?: string, form?: Record<string, string>, updated?: number, added?: number }} props
*/
function AdminPage({ user, error, form = {}, updated, added }) {
const books = getBooks();
return (
<Layout title="Admin">
<h1>Manage books</h1>
<form method="post" action="/admin/logout" class="logout">
Logged in as {user}.<button>Log out</button>
</form>
<h2>Add a book</h2>
{error && <p class="error">{error}</p>}
<form method="post" class="card">
<label>
Title <input name="title" value={form.title} required />
</label>
<label>
Author <input name="author" value={form.author} required />
</label>
<label>
ISBN <input name="isbn" value={form.isbn} />
</label>
<label>
Status <StatusSelect value={form.status} />
</label>
<button class="primary">Add book</button>
</form>
<h2>All books</h2>
<ul class="books">
{books.map((book) => (
<li id={`book-${book.id}`}>
<span class="title">{book.title}</span>
<div class="actions">
{book.id === updated && <span class="saved">✓ Saved</span>}
{book.id === added && <span class="saved">✓ Added</span>}
<form method="post">
<input type="hidden" name="action" value="status" />
<input type="hidden" name="id" value={book.id} />
<StatusSelect value={book.status} />
<button>Save</button>
</form>
<form method="post" data-confirm={`Delete "${book.title}"?`}>
<input type="hidden" name="action" value="delete" />
<input type="hidden" name="id" value={book.id} />
<button class="danger">Delete</button>
</form>
</div>
</li>
))}
</ul>
</Layout>
);
}
/**
* @param {{ value?: string }} props
*/
function StatusSelect({ value }) {
return (
<select name="status">
{Object.entries(STATUS).map(([key, label]) => (
<option value={key} selected={value === key}>
{label}
</option>
))}
</select>
);
}
Open http://localhost:3000/admin. As you are not logged in yet, you are redirected to the login page. Log in with the password secret, add your favorite book and change the status of another one. After adding a book or saving a status, the redirect jumps to the book via its anchor in the URL (e.g. #book-2) and passes ?added=2 or ?updated=2, so the page can confirm the change. You'll see your changes on the home page and in the JSON API. When you're done, click "Log out".
This is the Jeasx way: a lean core, plus the full power of Fastify and esbuild if you need more. Need file uploads, rate limiting or sessions? Just register another Fastify plugin.
Step 9: Add CSS and JavaScript
Your app works, but it doesn't look nice yet. Files named index.css or index.js (or .ts, .jsx, .tsx) in src are bundled by esbuild for the browser. Create src/browser/index.css:
@view-transition {
navigation: auto;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
:root {
color-scheme: light dark;
--background: light-dark(#f6f6f8, #141418);
--surface: light-dark(#ffffff, #1e1e24);
--text: light-dark(#1c1c21, #ececf1);
--muted: light-dark(#6b6b76, #9d9da8);
--border: light-dark(#e2e2e8, #33333c);
--accent: light-dark(#4f46e5, #818cf8);
--on-accent: light-dark(#ffffff, #141418);
--danger: light-dark(#dc2626, #f87171);
--success: light-dark(#16a34a, #4ade80);
--radius: 0.5rem;
font-family: system-ui, sans-serif;
line-height: 1.5;
accent-color: var(--accent);
}
body {
max-width: 48rem;
margin: 0 auto;
padding: 1rem;
background: var(--background);
color: var(--text);
}
a {
color: var(--accent);
}
/* Header & navigation */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.logo {
font-weight: 700;
color: var(--text);
text-decoration: none;
}
header nav,
.filter {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
header nav a,
.filter a {
padding: 0.25rem 0.75rem;
border-radius: 999px;
color: var(--muted);
text-decoration: none;
}
header nav a:hover,
.filter a:hover {
background: var(--surface);
color: var(--text);
}
header nav a[aria-current="page"],
.filter a[aria-current="page"] {
background: var(--accent);
color: var(--on-accent);
}
/* Book list */
.books {
display: grid;
gap: 0.5rem;
padding: 0;
list-style: none;
}
.books li {
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.books li:has(form) {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.books li:target {
border-color: var(--success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 25%, transparent);
}
.books li a {
display: inline-block;
font-weight: 600;
text-decoration: none;
}
.books li a:hover {
text-decoration: underline;
}
.books .title {
flex: 1 1 12rem;
}
.books :is(.actions, form) {
display: flex;
align-items: center;
gap: 0.25rem;
}
.books :is(select, button) {
padding-block: 0.25rem;
font-size: 0.85rem;
}
.badge {
display: inline-block;
padding: 0 0.6rem;
border-radius: 999px;
background: color-mix(in srgb, var(--muted) 15%, transparent);
color: var(--muted);
font-size: 0.75rem;
font-weight: 600;
vertical-align: middle;
}
.badge.reading {
background: light-dark(#fef3c7, #422006);
color: light-dark(#92400e, #fcd34d);
}
.badge.finished {
background: light-dark(#dcfce7, #052e16);
color: light-dark(#166534, #86efac);
}
.saved {
color: var(--success);
font-size: 0.85rem;
font-weight: 600;
}
/* Book details */
.book {
display: flex;
flex-wrap: wrap;
gap: 2rem;
margin-top: 1.5rem;
}
.book > div {
flex: 1 1 16rem;
}
.book h1 {
margin-top: 0;
}
.book img {
height: auto;
border-radius: var(--radius);
box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.2);
}
/* Skeleton shown while the cover image loads: a fake book with spine,
title and author lines, and a diagonal shine sweeping across it. */
.cover {
--bar: color-mix(in srgb, var(--surface) 55%, transparent);
/* 2:3 while loading, then the cover's own proportions */
aspect-ratio: auto 2 / 3;
align-self: flex-start;
background:
linear-gradient(110deg, transparent 40%, rgb(255 255 255 / 0.35) 50%, transparent 60%)
100% 0 / 250% 100% no-repeat,
linear-gradient(var(--bar), var(--bar)) 50% 32% / 70% 6% no-repeat,
linear-gradient(var(--bar), var(--bar)) 50% 41% / 50% 6% no-repeat,
linear-gradient(var(--bar), var(--bar)) 50% 83% / 40% 4% no-repeat,
linear-gradient(90deg, rgb(0 0 0 / 0.18) 0 6%, rgb(255 255 255 / 0.12) 6% 7%, transparent 7%),
linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 35%, var(--surface)),
color-mix(in srgb, var(--accent) 12%, var(--surface))
);
animation:
cover-shine 1.6s ease-in-out infinite,
cover-float 3s ease-in-out infinite;
}
.cover.loaded {
background: none;
animation: cover-reveal 0.5s ease-out;
}
@keyframes cover-shine {
to {
background-position: 0 0, 50% 32%, 50% 41%, 50% 83%, 0 0, 0 0;
}
}
@keyframes cover-float {
50% {
transform: translateY(-4px) rotate(-1deg);
}
}
@keyframes cover-reveal {
from {
opacity: 0.3;
filter: blur(8px);
transform: scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
.cover,
.cover.loaded {
animation: none;
}
}
/* Forms */
.card {
display: grid;
gap: 1rem;
max-width: 24rem;
padding: 1.5rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) * 2);
background: var(--surface);
}
.card label {
display: grid;
gap: 0.25rem;
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
}
.card :is(input, select) {
color: var(--text);
font-size: 1rem;
font-weight: normal;
}
input,
select,
button {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
font: inherit;
transition:
border-color 0.15s,
box-shadow 0.15s,
background-color 0.15s;
}
select {
appearance: none;
padding-right: 2rem;
background: var(--surface)
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23888'%3E%3Cpath d='M4 6l4 4 4-4z'/%3E%3C/svg%3E")
no-repeat right 0.5rem center / 1rem;
}
:is(input, select, button):hover {
border-color: var(--muted);
}
:is(input, select, button):focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
}
button {
cursor: pointer;
font-weight: 600;
}
button.primary {
border-color: var(--accent);
background: var(--accent);
color: var(--on-accent);
}
button.primary:hover {
background: color-mix(in srgb, var(--accent) 85%, var(--text));
}
button.danger {
color: var(--danger);
}
button.danger:hover {
border-color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
}
.error {
padding: 0.75rem 1rem;
border-radius: var(--radius);
background: color-mix(in srgb, var(--danger) 12%, transparent);
color: var(--danger);
}
.logout {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logout button {
padding-block: 0.25rem;
font-size: 0.85rem;
}
The stylesheet defines its colors as custom properties with light-dark(), so the app follows the light or dark mode of your system.
Deleting a book by accident would be sad, so let's ask for confirmation first. Create src/browser/index.js:
// Ask for confirmation before submitting forms with a data-confirm attribute.
document.querySelectorAll("form[data-confirm]").forEach((form) => {
form.addEventListener("submit", (event) => {
if (!confirm(form.dataset.confirm)) {
event.preventDefault();
}
});
});
// Remove the skeleton animation once a cover image has loaded (or failed).
document.querySelectorAll("img.cover").forEach((img) => {
const done = () => img.classList.add("loaded");
if (img.complete) {
done();
} else {
img.addEventListener("load", done);
img.addEventListener("error", done);
}
});
Your layout already links both files. They are served at /index.css and /index.js, without browser in the URL. Why? esbuild builds all index.* files below src and keeps their folder structure, but relative to the deepest folder they all have in common. As both files live in src/browser, this common folder is src/browser itself, so it doesn't show up in the URL. The folder name browser is just a convention: you could also put your assets directly in src or in any other folder. Just keep in mind that the URLs change once your assets are spread over several folders. If you add src/admin/index.js, for example, the common folder becomes src, and your files are served at /browser/index.css, /browser/index.js and /admin/index.js. That's why it's a good idea to keep all browser assets in one folder.
Reload the page to see the styles. In the admin area, the CSS selector :target now highlights the book you've just updated, and deleting a book asks for confirmation first. On the detail page, an animated placeholder is shown while the cover image loads. Once the image has loaded, the script adds the class loaded and the cover fades in.
Opening a book is animated now, too. @view-transition turns on view transitions between the pages of your site. The link in the list and the heading on the detail page share the same view-transition-name (e.g. book-2), so the browser moves the title from the list into the heading while the rest of the page cross-fades. Two details: in Jeasx, the style attribute takes a plain string, and the link is an inline-block, because a title which wraps over several lines can't be animated. Browsers without support for view transitions simply open the page as usual.
You can import CSS from other files, use npm packages in your scripts or add esbuild plugins (e.g. for Sass or Tailwind) in jeasx.config.js. Files which don't need processing, like images or robots.txt, belong in the public folder. See CSS & JavaScript for more details.
Step 10: Run in production
So far, you've used the dev server. For production, build your app once and start the server:
npm run build
npm start
The build minifies your code and creates an index of all routes and files, so the server starts quickly and doesn't scan your project for each request. Remember to set NODE_ENV=production on your server, so .env.production files are used and Fastify logs requests (see jeasx.config.js).
A Jeasx app is a regular Node.js server, so you can run it nearly everywhere: on a VPS, in a
Docker container or on Vercel. Create projects with --with-docker or --with-vercel to get the
needed files. Please note that the books of this tutorial live in memory, so they are reset on
every restart. See hosting for details.
What you've learned
Congratulations, you've built your first Jeasx app! Let's recap:
- Files with brackets are routes:
[index]for folders, named routes like[books.json], dynamic[...path]routes and[404]pages. All other files are regular modules. - Routes and components are functions which return JSX. They can be
async, and pages are streamed to the browser while they render. - Every route gets the Fastify
requestandreplyas props. Return JSX for HTML, or objects for JSON. - Guards run before routes. Use them to share data via
this, protect folders or pass props to routes. index.cssandindex.jsfiles insrcare bundled for the browser. Static files go intopublic..envfiles andjeasx.config.jslet you configure everything, using the full power of Fastify and esbuild.
Want to see more? The Expo shows a lot of examples, e.g. for cookies, file uploads, HTMX or Preact. Or continue with the essentials to learn all the details.