jskelet
v0.6.3
Published
A framework that feels like no framework: Express 5 + build-time .jsk SSR (optional EJS peer), vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.
Maintainers
Readme
JSkelet
A framework that feels like no framework — for sites where SEO and speed are the product.
JSkelet renders complete HTML on an Express 5 server from build-time
.jsk templates (optional EJS peer for legacy .ejs), adds interactivity
through vanilla JS islands, compiles CSS into a single Tailwind v4
stylesheet, and instead of ISR keeps an in-process HTML TTL cache with
stale-while-revalidate — plus optional Redis sharing and path-based
invalidation. No React — the framework source is plain JavaScript with JSDoc;
apps can write client islands and entries in TypeScript, and the published
package ships declaration files.
- Quick start
- What it looks like
- How it works
- What you get
- What it deliberately does not do
- Project layout
- Configuration
- CLI
- Public API
- Deployment
- Documentation
- Examples
- Contributing
Quick start
mkdir my-site && cd my-site
npm init -y && npm pkg set type=module
npm install jskelet
npm install -D postcss @tailwindcss/postcss tailwindcss lightningcss
npx jskelet init
npx jskelet devhttp://localhost:3000 serves a page that was rendered on the server, stored in
the HTML cache, and whose island hydrates when it scrolls into view.
Requirements:
- Node.js 22 or newer.
- Everything else is an optional peer dependency:
postcss,@tailwindcss/postcss,tailwindcssandlightningcssfor styles,@phosphor-icons/corefor the icon sprite,sharpfor image optimization,ioredisfor the shared Redis cache tier. If a package is missing, the matching step is skipped with a warning and the site keeps working.
What it looks like
A feature (or route) module receives the app and registers URLs explicitly. Nothing is inferred from the file system.
// features/home/index.js
export default function register(app, { route, notFound }) {
app.get("/", route(
async () => ({
view: "pages/home",
metadata: { title: "Home", canonical: "/" },
data: { posts: getPosts() },
}),
{ revalidate: 60 },
));
app.get("/blog/:slug", route(async ({ params }) => {
const post = getPost(params.slug);
if (!post) notFound();
return { view: "pages/blog-post", data: { post } };
}));
}Templates are .jsk: compiled to ESM at build time (no request-time parse or
eval). Named exports under views/components/** become PascalCase tags —
plain functions that return HTML strings.
<!-- features/home/views/pages/home.jsk -->
<section class="wrapper">
<h1 class="text-3xl font-bold">Latest posts</h1>
{#each posts as post}
<PostCard :post="post" />
{/each}
<div data-island="newsletter"></div>
</section>Islands export a named mount(element, props) and are registered from the
client entry as dynamic imports.
// features/home/client/newsletter.js
export function mount(el) {
const form = el.querySelector("form");
form.addEventListener("submit", async (event) => {
event.preventDefault();
await fetch("/api/subscribe", { method: "POST", body: new FormData(form) });
});
}How it works
A request goes through a fixed middleware order that is documented in the
numbered comment at the top of src/server/create-app.js; changing that order
causes silent breakage.
- Config (
jskelet.config.mjs) is loaded once and exposed throughgetConfig().redirects(),rewrites(),headers(),cache()andadmin()follow the subset ofnext.configsyntax people actually use. A broken config, a throwingheaders()or a failing hook logs a warning and falls back to defaults — it never takes the site down. - Build turns
.jskinto modules under.jskelet/templates/, bundles islands, compiles Tailwind, hashes assets and optionally precompresses them. - Static assets are served from
public/with hashed filenames and long-lived cache headers; precompressed.br/.gzvariants are picked automatically. route()wraps your controller. It builds a cache key, checks the HTML TTL cache, and on a miss renders the page. When a cached entry is stale it is returned immediately while revalidation runs in the background.- Render composes metadata, layout context and your view into one HTML
document. Hooks (
metadata,layoutContext,notFound,prewarmPaths) are where application knowledge lives — the framework itself carries none. - Hydration happens in the browser: the island registry finds
data-islandelements and dynamically imports the matching chunk when it becomes visible (or eagerly / on idle, if asked).
Because cached HTML is shared by every visitor, nothing personalized may appear
in a page rendered through route(). Per-user markup belongs in separate
fragment endpoints marked no-store, and decisions like theme are made on the
client.
What you get
- Full HTML from the server. First paint does not wait for JavaScript, and crawlers see the complete document because content is never assembled in the browser.
- Build-time
.jsktemplates. Declarative HTML-like syntax compiled to ESM before the server starts; EJS remains supported where both exist,.jskwins. A VS Code / Cursor extension underextensions/vscode-jskcovers highlighting and snippets. - Feature-first layout.
features/<name>/co-locates routes, views, components and islands;jskelet generate feature|page|islandscaffolds the next slice. URLs stay explicit. - Islands. Interactivity attaches to elements carrying
data-island. Modules are dynamically imported on visibility by default;data-island-eageranddata-island-idlepick a different strategy. A small store handles sharing state between islands. - HTML TTL cache. Per-route
revalidate, stale-while-revalidate on expiry, query allowlists, dependency tracking fromwithDataCache, and prewarm that fills the cache at boot.invalidateHtmlCache()stales a path, pattern or RegExp without flushing everything. - Optional Redis tier. With
ioredis, replicas share HTML/data and broadcast invalidation over pub/sub so a webhook reaches every process. - Admin panel. Opt-in at
/_jskelet/admin(admin()orJSKELET_ADMIN=1): cache inventory, targeted purge, Cloudflare CDN controls, live logs, routes and system meters — password printed once per process start. - Fast navigation. The
navigationconfig section emits Speculation Rules to prefetch or prerender links and enables view transitions — without adding any client runtime. - Familiar configuration.
redirects(),rewrites(),headers(),cache(),admin(), plusbrand,images,security,logs,trailingSlashandhooks. - A real build pipeline. Fonts, an SVG sprite from the icons you use, Tailwind v4 CSS, esbuild bundles with code splitting, webp variants, hashed output and brotli/gzip precompression.
- Developer experience. One command, one terminal: watch build plus server, CSS hot-swap, automatic restart, and a devtools overlay on Alt+D showing requests, errors, upstream calls, a cache dump and Web Vitals.
- Graceful degradation. Without build output
asset()returns the unhashed path andhasAsset()returns false, so forgettingjskelet buildyields an unstyled but working page instead of a crash.
What it deliberately does not do
- No file-system routing. Paths are written explicitly in route modules.
- No streaming or RSC. A page is flushed as one document; slow sections are fetched from separate fragment endpoints.
- No Next.js-style cache tags. Invalidation is by path, pattern or RegExp
(
invalidateHtmlCache), plus data-cache dependency tracking — not arbitrary tag graphs. - No global state management beyond the small island store.
An app-shaped interface behind a login — a dashboard, an editor, an admin panel
— cannot benefit from the HTML cache, which is the main reason to pick this
framework. It is supported rather than recommended: route(fn, { private: true })
keeps per-visitor pages out of the cache, and signed cookies, CSRF, fragment
endpoints and region swapping cover the rest
(docs/12-panel-ve-oturum.md /
docs/en/12-dashboards-and-sessions.md).
Live data transport is deliberately left to you; pick SSE, WebSocket or polling
yourself. A feature-by-feature comparison with Next.js is in
docs/11-tasima.md /
docs/en/11-migration.md.
Project layout
jskelet init scaffolds this shape, and every directory is configurable through
the paths section of the config:
my-site/
├── jskelet.config.mjs # config, hooks, headers, redirects
├── features/ # feature-first slices (optional but default in init)
│ └── home/
│ ├── index.js # register(app, api) — URLs stay explicit
│ ├── views/pages/ # .jsk pages for this feature
│ ├── views/components/
│ ├── client/ # islands; register from client/entries
│ └── server/
├── routes/ # optional; loaded before features (10-, 20-, …)
├── views/ # shared / app-wide pages (e.g. 404)
├── shared/ # cross-feature server/views/client
├── client/entries/main.js # registers islands, calls start()
├── styles/globals.css # Tailwind entry with @source directives
└── public/ # build output plus static filesTwo things bite newcomers:
- Tailwind class scanning follows
@sourcedirectives instyles/globals.css, because automatic detection is turned off withsource(none). A new directory that uses classes needs an@sourceline, or its classes silently vanish from the stylesheet. .jskexpression language is intentionally narrow. Formatting and object literals belong in JS components (views/components/**), not in the template.
Configuration
jskelet.config.mjs exports a single object. Every section is optional.
export default {
brand: { name: "My Site", lang: "en" },
icons: { scan: ["views", "features", "client"] },
// Speculation Rules plus @view-transition, with no client runtime.
navigation: { prefetch: "moderate", prerender: "conservative", viewTransition: true },
async redirects() {
return [{ source: "/old", destination: "/new", permanent: true }];
},
async headers() {
return [{ source: "/:path*", headers: [{ key: "X-Frame-Options", value: "DENY" }] }];
},
async cache() {
return {
html: { "/": 3600, "/pricing": 3600 },
query: { "/search": ["q"] }, // only these params enter the cache key
prewarm: { enabled: true, max: 50, concurrency: 4 },
// redis: { enabled: true, url: process.env.REDIS_URL },
};
},
// Opt-in production panel at /_jskelet/admin (password in the server log).
async admin() {
return { enabled: false };
},
hooks: {
metadata: () => ({ titleTemplate: "%s · My Site", siteUrl: "https://example.com" }),
layoutContext: ({ pathname }) => ({ pathname, year: new Date().getFullYear() }),
prewarmPaths: async () => ["/", "/pricing"],
},
};The complete reference — every field, default and failure mode — is docs/07-yapilandirma.md / docs/en/07-configuration.md.
CLI
| Command | What it does |
| --- | --- |
| jskelet dev | Watch build plus server, live reload, devtools overlay. --murder kills whatever already holds PORT and starts. |
| jskelet build | Production build: templates → fonts → sprite → CSS → JS → images → manifest → precompress |
| jskelet start | Production server; builds first if output is missing. --murder same as for dev. |
| jskelet init | Scaffolds a feature-first .jsk skeleton into the current directory |
| jskelet generate | Scaffolds a feature / page / island |
Public API
Only the specifiers in the exports map are supported:
| Specifier | Contents |
| --- | --- |
| jskelet | route, fragment, createApp, startServer, notFound, redirect, seeOther, cache, asset, getConfig, cookie helpers, HTML/data cache and prewarm helpers, Redis/Cloudflare status and purge helpers |
| jskelet/client | register, registerAll, hydrate, unmount, start, swap, startForms, createStore, DOM helpers |
| jskelet/html | attrs, cn, cx, esc, jsonScript |
| jskelet/tags | icon, image, link, preloadImage, csrfField |
| jskelet/cookies | parseCookies, setCookie, clearCookie, setSignedCookie, getSignedCookie, randomToken, safeEqual |
Anything reachable by a deeper path is internal and may change without notice.
Deployment
The server is a plain Express 5 app, so anything that can run a Node process
works: a multi-stage Dockerfile (see docs/10-dagitim.md),
a systemd unit, or a PaaS. Run jskelet build at image build time, put a
reverse proxy in front for TLS, and expose a health endpoint (the default
dev gate bypass list already includes /api/healthcheck, so a route there is
reachable in every mode). Details, including cache sizing behind multiple
instances and the optional Redis tier, are in
docs/10-dagitim.md /
docs/en/10-deployment.md.
Documentation
Full reference in Turkish under docs/ and in English under docs/en/. Both editions are kept in sync.
| Document (TR / EN) | Topic |
| --- | --- |
| 01-baslangic / getting-started | Installation, first route, first island, directory layout, CLI |
| 02-mimari / architecture | Decisions and their reasoning, middleware order |
| 03-routing / routing | Route modules, controller contract, load order |
| 04-render / rendering | .jsk / EJS, layout, components, helpers, metadata |
| 05-islands / islands | Island contract, hydration, store, DOM helpers |
| 06-cache / caching | TTL, SWR, keys, prewarm, Redis, invalidation, admin |
| 07-yapilandirma / configuration | Complete jskelet.config.mjs reference |
| 08-build / build | Build pipeline, manifest, Tailwind @source, sprite |
| 09-dev / dev-tools | Dev workflow, overlay, report page, dev gate |
| 10-dagitim / deployment | Production, Docker, reverse proxy, health checks |
| 11-tasima / migration | Migrating from Next.js: mapping table and plan |
| 12-panel / dashboards | Per-visitor pages: private: true, sessions, CSRF, fragments |
If you work with AI agents, AGENTS.md summarizes the rules that apply to this repository.
Examples
npm --prefix examples/minimal install && npm --prefix examples/minimal run dev
npm --prefix examples/blog install && npm --prefix examples/blog run dev
npm --prefix examples/dashboard install && npm --prefix examples/dashboard run devexamples/minimal— two routes, one component, one island, plus a co-locatedfeatures/demoslice. The smallest thing that runs.examples/blog— dynamic routes, tag pages, every config section, fragment-loaded tabs, a form, prewarm, RSS and sitemap, four islands. It intentionally touches every surface of the framework.examples/dashboard— the opposite axis: per-visitor pages. A signed cookie session, aprivate: truepage that never enters the HTML cache, a paginated table fragment, a CSRF-protected form that still works without JavaScript, and an island with cleanup. A public landing page sits next to it, so a cached response and ano-storeone are visible side by side.
With a server running, node smoke.mjs inside an example verifies that its
endpoints respond as expected.
Contributing
Bug reports, documentation fixes and pull requests are welcome. Start with CONTRIBUTING.md for the workflow and local checks, and note that participation is covered by our Code of Conduct. Security issues should follow SECURITY.md instead of the public issue tracker.
npm install
npm run lint
npm testLicense
MIT © JSkelet contributors
