npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@azerothjs/kit

v2.1.0

Published

The assembled car: per-route SSR, static prerendering, hydration, and one-command production builds over the AzerothJS router, renderer, and HTTP server.

Downloads

393

Readme

@azerothjs/kit

Per-route SSR, static prerendering, hydration, and one-command production builds over the AzerothJS router, renderer, and HTTP server.

npm License: MIT Node >= 22


Part of AzerothJS - the fine-grained fullstack framework. This is the assembled car: per-route SSR, static prerendering, and hydration wired over the pieces you already have - the router's route table, the renderer, and @azerothjs/http. The kit invents nothing: no new routing system, no new data layer, no config format. It replaces the ~10 lines of hand-wiring every fullstack app otherwise repeats.

  • Per-route rendering modes - server (SSR per request), static (prerendered at build), or client (SPA shell), chosen with one field on the route you already declared.
  • Server-correct guards and loaders - a redirecting guard becomes a real 302, a vetoed route serves the shell with the guard's status, and parallel loaders stream into the hydration handoff.
  • One-command production - mountPages registers pages plus asset fallback on an @azerothjs/http app, azeroth-kit-prerender writes the static pages, and bootClient hydrates or renders in the browser.

Install

[!NOTE] ESM-only, Node >= 22. azerothjs is a required peer:

npm install @azerothjs/kit azerothjs

The whole idea

Your router's route table is the manifest. Add one optional field per route and the kit does the rest:

import { defineRoute } from 'azerothjs';
import type { PageRoute } from '@azerothjs/kit';

export const routes: PageRoute[] = [
    { path: '/', component: Home, render: 'static' },       // prerendered at build
    { path: '/guestbook', component: Guestbook },            // SSR per request (the default)
    { path: '/settings', component: Settings, render: 'client' } // SPA shell; browser renders
];
  • render: 'server' - SSR per request. Loader guards become real HTTP 302s, parallel loaders stream into the hydration handoff, the client picks up without refetching. The default when a renderer is provided.
  • render: 'static' - rendered once at build by azeroth-kit-prerender, served as a file.
  • render: 'stream' - streaming SSR: the shell flushes immediately and each <Suspense> boundary follows as it settles.
  • render: 'client' - the pristine SPA shell; the browser renders. The default with no renderer.

Modes inherit down children. A parameterized path (/users/:id) needs staticParams to be 'static' - that enumerates which param sets get prerendered, and any value it does not list falls through to a live render. Without staticParams a parameterized static route is a build error, because one pattern cannot become one file.


The three calls

Server entry (entry.server.ts, built with vite build --ssr):

import { createPageRenderer } from '@azerothjs/kit/ssr';
import { App } from './App.azeroth';
import { routes } from './routes.ts';

export { routes };
export const renderPage = createPageRenderer(App, routes);

Client entry (main.ts):

import { bootClient } from '@azerothjs/kit/client';
import { App } from './App.azeroth';

bootClient(App); // hydrates SSR/static pages, renders client pages - it checks the root

HTTP server:

import { App } from '@azerothjs/http';
import { mountPages } from '@azerothjs/kit';
import { renderPage, routes } from '../application/dist-server/entry.server.js';

const app = new App();
// ...your API routes first; they keep priority...
mountPages(app, { routes, clientDir: 'application/dist', renderer: renderPage });

That is the entire integration. mountPages registers each page in its mode plus asset fallback; register it last.


What a render: 'server' loader can do

A per-request render answers a real Request, and the kit hands it to everything that runs for that one visitor: every server guard walk, every level's loader, and the render itself, where useRequest() reads the same object. A loader can therefore read the visitor's cookie or authorization header and derive from it, instead of shipping a page that fetches its own data after hydration.

It can also call the app's own api without a socket. When the pages are mounted on an @azerothjs/http App that has registered an api, a typed client with a relative baseUrl dispatches through that App in process, carrying the visitor's identity:

{ path: '/guestbook', component: GuestBook, render: 'server', loader: () => client.guestbook.list() }

The rules that govern it - GET and HEAD only, which headers are forwarded, the nested request root, the signal and the deadline - belong to @azerothjs/http and are written up in its README under "Calling your own api in process". One of them decides how you compose your server: an in-process call enters at app.handle, so an outer pipeline(app, requestId(), securityHeaders(), csrfCookie(), rateLimit()) does not run for it. Keep rateLimit in that pipeline rather than in app.use - installed with app.use it runs on the in-process leg too, where its default key throws 500 rate-limit-key-unavailable because the request has no peer.

Reading identity makes a page private

A render that consulted the request is a function of (URL, visitor), not of the URL alone, so the kit answers it private, no-store and never caches, persists or shares it - the same treatment a guarded page gets, for the same reason. Reading args.request at all counts, destructuring the loader arguments included, as does a non-null useRequest() and an in-process api call. The over-approximation is deliberate: a page can lose shared cacheability it might have kept, never its correctness.

One residual worth knowing on a streamed page: its headers leave with the shell, so a read inside a <Suspense> continuation comes too late to mark it. Loader-phase and main-pass reads are complete before the first byte and do mark it.

Shared renders never see a request

A render whose output is written to disk or can be handed to more than one visitor is given no request at all: the build-time prerender, ISR's background regeneration, and ISR's first render of a page it has not cached yet, the one whose output seeds the cache. In those args.request and useRequest() are both null and no in-process api exists, which is what keeps one visitor's identity out of a cached entry or a build artifact.

That has a consequence to plan for. A static page whose loader needs identity or the api fails the build loudly, with the path named and nothing written. An ISR page the build never enumerated, a parameterised or wildcard path with revalidate and no staticParams, fails at its first live render and at every one after it, answering 500 private, no-store and caching nothing, exactly as it does today with a loader that rejects. A page that needs the visitor is render: 'server'.


Development - @azerothjs/kit/dev

devPages is that same mount, over the same route table and the same renderer, fed by vite instead of by a build. One process and one origin: your API, your pages and the HMR socket all answer on the server you already run, so dev serves the page contract production serves - guards as status, page actions, locale negotiation, ISR, streaming, the CSP nonce and the manifest splice included.

import { devPages } from '@azerothjs/kit/dev';
import { SSR_SOURCE_ENTRY } from '@azerothjs/kit/dev/entry';

const session = await devPages({
    root: appRoot,                      // the application half - vite's root and its config
    entry: SSR_SOURCE_ENTRY,            // 'src/entry.server.ts', loaded from source
    pages: { manifest, csrf },          // KitOptions without routes, renderer, shell, clientDir
    routes: (app) => registerApi(app),  // your api routes, registered once
    app: { dev: true, observe, onError }
});

const served = await serve(pipeline(session.app, requestId()), { before: session.before });
session.attach(served.server);          // the HMR socket rides this server
// session.close() from your shutdown handler

The entry module exports routes and renderPage - the two names the prerender bin and a production mount already read - and the session reloads it, re-reads index.html, re-runs transformIndexHtml over it and re-mounts the pages on the first request after a change. The mount runs with shell (that transformed html) in place of clientDir, which is the one thing mountPages needs different without a built client. A first build that fails answers an html navigation with a readable dev error page naming the file, the line and the frame, and every other request with the app's own error envelope; a LATER failure keeps the previous build serving and prints one line saying so. serverAnchor names the module the single-instance check resolves azerothjs from (it defaults to the kit's own). npm create azeroth@latest wires all of this into the fullstack template's server/src/main.ts.

HMR rides your server. Vite is given no socket of its own, so the client dials the page's own origin: no second port to bind, nothing to collide with, and a tab survives the server's own restart - it reconnects and reloads itself. attach(server) subscribes one 'upgrade' listener and an upgrade nobody claims still answers 404, so a WebSocket endpoint and the devtools bridge coexist on the same server (give attachWebSockets its own path).

Refused at startup, loudly, rather than failing silently later: a server.proxy (the session IS that seam), a base other than /, server.watch: null (no watcher exists at all, so nothing would ever rebuild - false is a real watcher and is fine), and port, clientPort or host under server.ws or its deprecated server.hmr spelling - each pins a socket target into the page that nothing listens on, or that only one machine can reach, and vite warns about none of them. A server.ws.path is honoured, and every other vite setting is yours. One reach is missing by construction: a plugin that mounts its OWN middleware is not seen, since only vite's own urls and the files under the root and publicDir are handed to it.

Vite's host check still guards vite's own urls. Under a hostname that is not an IP literal or *.localhost, put it in server.allowedHosts or the page loads from your server while vite answers 403 for the client script, your modules and the stylesheet.

Divergences from production, all of them:

  • render: 'static' pages render live - there is no build to serve a file from - and a revalidate page re-renders per request rather than serving a cached copy. cache is ignored.
  • images is a mount error: it needs a built client. Register /_image yourself, in the routes callback, with imageHandler({ root: 'application/public' }).
  • The per-request data cache is per request ROOT, so one cached() key read by an api route and again by a page render fetches twice.
  • The seam registers GET and POST catch-alls, so a routed page with no action answers Allow: GET, HEAD, POST where production answers GET, HEAD.
  • /index.html answers your app's 404; production serves the built shell.
  • An api route registered at a page's own path (in the routes callback) wins over the page, silently: the api routes live on the outer App and the pages behind its catch-all, where a production mount registers both on one App and refuses the conflict at startup.

The kit declares nothing about vite - no dependency, and no peer, optional or otherwise - so import('vite') loads the copy your own app installs and a production image built with npm ci --omit=dev carries none. Declare vite in the half that runs the session, as a devDependency, at the same range the application declares; the session refuses a loaded vite whose major is not 8, naming the path and the version it found.


Prerendering

vite build && vite build --ssr src/entry.server.ts --outDir dist-server
azeroth-kit-prerender --entry dist-server/entry.server.js --client dist

The bin renders every render: 'static' page through your real loaders and writes it into the client dist, preserving the untouched shell as shell.html for client pages. A static page whose route chain carries a guard is a loud build error - declaration-based, even when the guard would pass at build time: a prerendered file is served without ever running guards, so the combination cannot exist. The same rule refuses the page at mountPages, so a server-only upgrade against an old dist fails the deploy rather than serving stale guarded files. Move the guard into a server-rendered subtree, throw redirect() from a loader (live-rendered requests only; it never runs for prerendered bytes), or use render: 'server'. A static page that redirects during the build is equally a build error, and so is a parameterized static route with no staticParams to enumerate - one pattern cannot become one file. A failed build rolls back every file it had already written, so a partial site is never published. The pass is also available programmatically as prerender() from @azerothjs/kit/prerender.


Enumerated static routes - staticParams

A parameterized route prerenders by enumerating its param sets; anything the enumeration did not list falls through to live SSR at request time:

{
    path: '/blog/:slug', component: Post, render: 'static',
    staticParams: () => Promise.resolve([{ slug: 'hello' }, { slug: 'world' }])
}

The route table ships to the browser, so keep the closure browser-safe: inline data, or a dynamic import the client bundle never follows eagerly. Invalid param values (empty, /, dot segments) fail the BUILD, never become a path.

Page actions - action

A route may declare an action: what a plain <form method="post"> on that page posts to, with no client JavaScript. mountPages registers the POST for the page's own path (and, under prefix routing, for every language-prefixed one). The body is read first because the CSRF token travels in a hidden _csrf field, the token is verified, then the page's route-chain guards run through the same walk its GET runs, and only then does the action - so a write is gated by exactly what gates the page, and a guard's veto answers with the page's own blocked UI. Returning nothing is a 303 back to the page; returning a value re-renders it at 422 with the value in useActionResult(); throwing redirect(...) goes elsewhere, judged by the same off-origin rule as every redirect. An action is refused on a render: 'static' page, which has no request to mint a token for, and on a route with children. The router guide covers the page side and <Form>.

ISR - revalidate

A static page with revalidate serves from a page cache: fresh within the window, past it the stale copy answers instantly while exactly ONE background render replaces it, in the language the copy is filed under. Build output seeds the cache through file mtimes; a failed regeneration keeps the old copy, holds the key for one window so a fast-failing loader renders at most once per window, and reports one notice per distinct fault through onError with the failing levels as its cause; an outcome that stopped being static content (a redirect, a veto, a 404) drops the entry so a guard is never masked.

A page whose route chain carries a guard never enters the cache at all: mounting it as ISR is refused outright, and a URL that reaches an ISR or a static-file handler but matches a guarded chain elsewhere in the table renders live per request with cache-control: private, no-store and x-azeroth-cache: live (without a renderer, the guard walk itself answers). The prerender pass refuses to write a file for such a url, and a bare shell for one is never cacheable. A guard makes the page a function of the request's identity, and an identity-dependent page in a shared cache serves one visitor's data to the next - so the combination does not exist. Note the boundary: a LOADER that reads request identity without a guard is invisible to this rule; keep personalized loaders off ISR pages.

{ path: '/', component: Home, render: 'static', revalidate: 300 }

Responses carry age and x-azeroth-cache: hit | stale | miss (live marks a guarded URL answered per request, never cached). The cache is pluggable (KitOptions.cache): MemoryPageCache by default, FilePageCache to survive restarts, or your own PageCache. Background failures surface through KitOptions.onError.

MemoryPageCache is process-local, which is the right default for one instance and the wrong one for several: behind a load balancer each instance keeps its own copy, so a page can be fresh on one and stale on another, and revalidate regenerates once per instance rather than once per deployment. FilePageCache fixes that only for instances sharing a filesystem. Across machines, supply a PageCache backed by whatever store they already share.

A write invalidates the page it lands on

A page action that sends the visitor to an ISR page marks that page before the response leaves: the redirect it throws, the { ok, redirect } an enhanced submit reads, and a wildcard action answering at a cached sibling's url all count, so the visitor's next GET renders instead of being handed the copy made before the write. Nothing else marks - a guard redirect that fires BEFORE the action ran, a 422 re-render, a thrown fault and an unsafeUrl() target off this origin leave the ledger alone.

For a write that changes what ANOTHER ISR page shows - an api route that edits what /book renders - mark it yourself:

import { revalidate } from '@azerothjs/kit';

revalidate('/book');

Import it explicitly: azerothjs exports a revalidate of its own, which refreshes a cached() fetcher and throws on a string. The path is the app's own in any spelling - decoded or encoded, with or without a trailing slash, params filled in, no language prefix, a query ignored - because the unit of invalidation is the PAGE: one mark covers every key of it, both slash spellings, every query variant, and every language in both routing modes.

A mark costs one render per key on that key's next read, two for a query-bearing key (which re-earns its cache slot exactly as a cold one does), and nothing at all for a key nobody reads again; a mark landing inside a render already in flight costs one more, because that render read the data before the write and is answered only to the readers already waiting on it. Two standing effects are worth knowing. From the mark until the first successful render the page has no stale-on-error: a failing loader answers 500 private, no-store, because serving the kept copy would show the writer their pre-write page. And a marked page never seeds from its prerendered file while its mark stands - that file was written before the write by construction - so revalidate() at boot turns the seed off for that path. The ledger keeps the newest 4096 marked PAGES (marking a page again counts once, and moves it to the newest end): a page marked before 4096 other distinct pages drops out of it, which means it seeds again if nobody has read it since the write, and a copy of it made before the write and never re-read answers hit again until its window expires. Below that many written pages the bound is never reached.

The ledger is process-local in exactly the same way as MemoryPageCache: a write marks the instance it landed on. With per-instance caches the others keep serving their own copies until the window expires. With a SHARED cache there is one entry and the last writer wins, which makes the common case better than that - the marked instance's fresh render is what everyone gets - and the race case worse: a sibling instance whose render started before the write can land pre-write bytes stamped after the mark, which the dirty test cannot tell from a fresh copy, so that page answers hit with them for up to one window. That is a property of sharing a cache, not of the ledger. For a write that must be seen everywhere at once, fan revalidate() out to every instance.

The query string is part of the key

/search?q=shoes and /search?q=hats are different pages and get different entries. Parameter ORDER is not: ?a=1&b=2 and ?b=2&a=1 normalise to one entry. The renderer still receives the URL exactly as requested, so useSearch() reads what the visitor actually sent.

This makes cache cardinality a function of your traffic, not your route table - and campaign tags (?utm_source=...) each cost a render and an entry. Both shipped caches are bounded (maxEntries, default 1000; FilePageCache evicts oldest-first by mtime), so the ceiling is memory- and disk-safe, but a route that must ignore tracking parameters should strip them in a middleware before the kit sees the request. Only a request with NO query can be seeded from the prerendered file on disk, because that file was rendered without one.

The key is built from the path as sent, never a percent-decoded copy of it. That matters for any route whose parameter can carry a delimiter: /article/a%3Fb is a slug containing ?, and decoding before keying would make it collide with /article/a?b, a different page entirely. The cost is that two spellings of the same page (/tag/caf%C3%A9 and /tag/café) occupy two entries rather than one. That is a duplicate, never a wrong page, and the entry cap bounds it.

A cache bounds REPEAT cost, not FIRST-render cost

Worth stating plainly, because the cache invites the opposite assumption: nothing above puts a ceiling on how much rendering a single client can ask for. A cold key must be rendered before it can be served, distinct query strings are distinct cold keys, and render: 'server' is uncached by design and renders on EVERY request - it is also the default mode whenever a renderer is configured. The entry cap bounds what the cache KEEPS; it does not bound what the server RENDERS.

Bounding that is an arrival-rate decision about your traffic and your identities, which is why the kit does not guess at it. Put a limit at the edge:

import { rateLimit } from '@azerothjs/http';

app.use(rateLimit({ limit: 60, windowMs: 60_000 }));

It keys on the client IP through the same trusted-proxy boundary as clientIp, answers 429 with Retry-After, and stamps RateLimit-* headers so a well-behaved client paces itself before it is ever refused. Size it against your render budget, not just your cache hit rate.

That limit is load-bearing for ISR, not a nicety, and the reason is worth being precise about. It is tempting to assume open connections bound concurrent renders: a client waiting for a response cannot ask for another until it arrives. They do not. Measured at a fixed budget of 8 sockets over one second against a 60 ms render: a client that WAITS gets 104 renders and pins peak concurrency to 8, exactly its connection count. A client that issues the request and immediately drops the socket gets 1568 renders and 202 concurrent - about 15x the renders and 25x the concurrency from the same 8 sockets. Dropping the connection does not buy the same work more cheaply; it removes the only bound the server had, because a production outlives the request that started it and nothing caps how many run at once. Budget the limiter against that number, not against the polite one.

An abandoned production is deliberately NOT cancelled, and cancelling it would not help anyway:

  • On a cold path its output is exactly what the next visitor wants. Measured: five waiters abort, the render completes, and the next request is a hit with no second render. Aborting there would turn useful work into repeated work.
  • The loader fan-out cannot be un-issued. matchAndLoad dispatches every level of the chain synchronously, and a socket reset is not observable until after that has happened - measured, the fan-out goes out around 0.8 ms and the abort lands around 1.9 ms. No cancellation signal can beat it, so the bound has to be arrival rate, at the edge.

What the kit does cancel is work with exactly one waiter and no cache value: a render: 'server' page and a guarded live render both receive the request's disconnect signal. A coalesced ISR production does not, because it is shared with every request queued on the same key, and a background regeneration does not, because it has no waiting client at all.

A deploy makes a shared cache go cold

Every entry is stamped with a build identity - the client shell's content hash - and an entry from a different build is treated as stale, because its HTML references content-hashed assets the new build deleted. That is the correct call for correctness, and it has an operational consequence worth planning for: during a rolling deploy, instances on the new build reject every entry the old build wrote, so a shared FilePageCache (or your own PageCache) goes fully cold and every cached page regenerates once. Size the origin for that spike, or warm the cache after a deploy.

The same mechanism means two applications pointed at one cache directory cannot serve each other's HTML - their shells hash differently, so each rejects the other's entries. They will, however, evict each other under the entry cap, so give each app its own directory.

Streaming SSR - render: 'stream'

The shell (loader handoff included) flushes before slow data resolves; each pending <Suspense> boundary streams its settled children as an out-of-order chunk that swaps in and hydrates without refetching. Redirects, guard vetoes, and HEAD stay buffered - they resolve before any byte exists.

{ path: '/dashboard', component: Dashboard, render: 'stream' }

Under a strict Content-Security-Policy - scriptNonce

A page emits two kinds of inline tag: the swap-runtime scripts a streamed page needs, and the scoped-CSS <style> a server-rendered page carries. Both are refused by a policy without 'unsafe-inline', so pass the request's nonce and name it in both directives:

mountPages(app, { routes, clientDir, renderer, scriptNonce: (context) => nonceFor(context) });
default-src 'self'; script-src 'self' 'nonce-<N>'; style-src 'self' 'nonce-<N>'

Listing the nonce only under script-src is the trap: style-src then falls back to default-src, the stylesheet is refused, and the page paints unstyled while looking correct in the DOM. Getting the script side wrong is louder - every <Suspense> boundary sits on its fallback until hydration refetches, and the streamed bytes are wasted.

css() on the client does not need a nonce: it adds a constructable stylesheet, which CSP does not govern.

The image endpoint - /_image

KitOptions.images: true serves <Image optimize>'s URLs over the client dist: content-hash cache keys, a year of immutable caching, ETag revalidation, and the same path containment static serving uses (one shared rule: a hidden name is refused under every spelling the filesystem answers to, an 8.3 alias included, and .well-known is public). The framework ships NO codec: without an adapter the endpoint is a caching passthrough of original bytes; implement ImageAdapter (one transform method) with whatever you trust to add resizing and AVIF/WebP negotiation. A throwing adapter degrades to original bytes - never a blank image. One consequence worth knowing before writing an adapter: real browsers always advertise image/avif, so with an adapter present the endpoint always negotiates a format - an adapter that THROWS on formats it cannot encode therefore falls back to the original bytes on every browser request, resize included. Produce what you can instead and say so through contentType (the adapter controls it for exactly this reason): a resized PNG answered to a webp ask is correct, cache-keyed separately, and keeps the resize win. Replacing an image in place keeps its URL, so browsers may cache the old bytes for up to a year; rename the file (hashed builds do) or lower cacheControl when images mutate.

Languages - locales

Declare the languages the site publishes and every page is served in the reader's own, with its <html lang> and <html dir>, its message catalogue, and what it varies on told to every cache in front of it:

mountPages(app, {
    routes, clientDir, renderer,
    locales: { supported: ['en', 'fa'], default: 'fa', acceptLanguage: false }
});

supported and default are the LocaleConfig every handler can negotiate with; cookie: false and acceptLanguage: false switch a source off (the latter makes every first visit land in the site's own language, with a reader's choice still winning). routing: 'prefix' gives every language its own url with hreflang between them, which is what a site with search-engine ambitions and a CDN wants. The full story - negotiation order, catalogues, the two routing modes and what each tells a shared cache - is the i18n guide.

Under routing: 'prefix' a prerendered page is served from its file for every spelling of its url, and the prerender pass writes the page's hreflang set into each artifact when it is told the mode (prerender({ ..., locales, routing: 'prefix' })).

The prefix reaches the client the way the language does. Every response that lives under one carries <html data-azeroth-base="/fa">, the render is pinned to it, and a router created with no base adopts it, so /fa/about matches your /about route on both sides, the served anchors already read /fa/..., <Link to="/about"> and <Form> stay in the language, and a server redirect to /login answers /fa/login. Your route table stays written as /about; nothing in the app names the prefix. A per-language artifact served under a prefix must carry that stamp, which the prerender writes when it is told the mode; a build that ran without routing: 'prefix' answers 500 for that file and names the option through onError, rather than serving a page that hydrates into the fallback. setLocale() in a prefixed document navigates to the sibling url, since the url is the language there.

What it deliberately is not

  • Not a router. The table above is azerothjs's own router table - loaders, lazy:, typed defineRoute handles all work unchanged. Guards work unchanged on server-rendered pages, and are REFUSED on static ones (see Prerendering and ISR above).
  • Not a data layer. Route loaders ARE the data story; the kit just carries their results across the wire as the hydration handoff.
  • Not a bundler. Vite builds both halves; the kit consumes the output.

npm create azeroth@latest scaffolds the fullstack template with all of this wired.


Related

  • AzerothJS - the monorepo overview and the full package list.
  • azerothjs - the framework runtime, router, and renderer (required peer).
  • @azerothjs/http - the web-standard HTTP server mountPages registers onto.
  • @azerothjs/compiler - the .azeroth compiler and Vite plugin that build both halves.