Skip to content

About

Slick is a small, simple, and ultrafast web framework built on Web Standards for Deno.

Topics

Resources

Stars

4 stars

Watchers

1 watching

Forks

Latest commit

Β 

History

57 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Welcome to Slick Server!

Slick is a small, simple, and ultrafast web framework built on Web Standards for Deno.

issues-closed Β  license Β  stars Β  forks


✨ Features

  • Ultrafast & lightweight Built on @webtools/expressapi and the native Deno HTTP stack with minimal overhead.
  • Server-side rendering Pages and templates are rendered to HTML with Preact for great SEO and fast first paint.
  • Typed from end to end req.data filled by a template's middleware is typed in its head, body and favicon, and in the title, head, body, onrequest and onpost of every page that uses it. URL params are typed too.
  • Islands architecture Ship static HTML by default, then hydrate only the interactive components you mark as islands.
  • Zero-config asset pipeline CSS, JS and TS files in static/ are transpiled and minified on the fly with esbuild, cached in production and served with an ETag.
  • Hot reload Optional dev mode that watches your workspace and reloads the pages, templates and islands affected by a change, without restarting the server.
  • SPA navigation Optional client mode (@webtools/slick-client) swaps pages over fetch without full reloads, while keeping SSR for the first request.
  • Bring your own npm libraries Use any npm/JSR package inside islands through automatic, shared, deduplicated vendor bundles and an import map.
  • Simple by default Sensible defaults, a tiny configuration surface, and a predictable folder convention.

πŸ“– Table of Contents

🧠 How It Works

Slick is built around three simple building blocks and a convention-based file system.

             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
HTTP GET  β†’  β”‚  Template  +  Page   β†’  SSR (Preact)  β†’ HTML  β”‚  β†’ Browser
             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                       β”‚
                       β”œβ”€ Islands are detected during SSR and tagged in the HTML
                       β”‚
Browser   β†’  loads the island hydration script
                       β”‚
                       └─ Each tagged element is hydrated with its Preact component,
                          pulling shared libraries from a generated import map.
  1. Templates describe the shared shell of your site (header, footer, <head> content). A template must contain an element with id="app" where page content is injected.
  2. Pages describe a single route. Each page references a template and provides its own title, head, body, styles, scripts and handlers.
  3. Islands are interactive Preact components that are rendered on the server and hydrated on the client, only the islands ship JavaScript, the rest stays static HTML.

When client mode is enabled, navigation between pages happens over fetch: the server returns a JSON payload (only the parts that changed) and the client patches the DOM instead of reloading the page.

πŸ“¦ Installation

Step 1: Add the package

deno add jsr:@webtools/slick-server

Step 2: Configure deno.json

JSX must be configured for Preact, otherwise pages and templates won't compile. Preact must be in your imports. preact-root-fragment too, as soon as the workspace has an islands/ directory: the hydration script imports it, and Slick bundles the version you declare.

{
	"imports": {
		"@webtools/slick-server": "jsr:@webtools/slick-server@^0.7.0",
		"preact": "npm:preact@^10.29.2",
		"preact-root-fragment": "npm:preact-root-fragment@^0.3.1"
	},
	"compilerOptions": {
		"jsxImportSource": "preact",
		"jsx": "react-jsx"
	}
}

Slick targets Preact 10. Preact 11 is not supported yet.

Optional: Enable SPA mode

Client mode (client: true) bundles @webtools/slick-client from the version declared in your project's deno.json:

"@webtools/slick-client": "jsr:@webtools/slick-client@^0.3.0"

πŸš€ Quick Start

1. Create the project structure

my-slick-app/
β”œβ”€β”€ pages/
β”‚   └── index.tsx
β”œβ”€β”€ templates/
β”‚   └── app.tsx
β”œβ”€β”€ static/
β”‚   β”œβ”€β”€ styles/
β”‚   └── scripts/
└── server.ts

2. Start the server (server.ts)

import { Slick } from "@webtools/slick-server";

const app = new Slick(import.meta.dirname!, {
	env: {
		API_URL: Deno.env.get("API_URL") ?? "http://localhost:3000",
	},
	port: 5000,
	lang: "en",
	r404: "/",
	client: true, // Enable SPA navigation
	hotReload: true, // Reload pages, templates and islands on file change
});

await app.start();

3. Create a template (templates/app.tsx)

import type { Template } from "@webtools/slick-server";

export default {
	name: "app",
	favicon: "/assets/favicon.ico",

	styles: ["/styles/reset.css", "/styles/app.css"],
	scripts: ["/scripts/app.ts"],

	head: <meta name="description" content="My Slick App" />,
	body: (
		<>
			<header>
				<nav>
					<a href="/">Home</a>
					<a href="/about">About</a>
				</nav>
			</header>
			<main id="app">{/* Page content is injected here */}</main>
		</>
	),
} satisfies Template;

4. Create a page (pages/index.tsx)

import type { Page } from "@webtools/slick-server";
import app from "../templates/app.tsx";

export default {
	url: "/",
	template: app,
	title: "Home - My App",

	styles: ["/styles/pages/index.css"],
	scripts: ["/scripts/pages/index.ts"],

	head: <meta property="og:title" content="Home" />,
	body: (
		<>
			<h1>Welcome to Slick Server!</h1>
			<p>This is your home page.</p>
		</>
	),
} satisfies Page<typeof app>;

5. Run it

deno run -A server.ts

Slick relies on esbuild (for transpiling assets, islands and vendor bundles) and dynamically imports your pages, templates and islands. The -A flag is the simplest way to grant the required permissions. For a tighter sandbox you need at least --allow-net --allow-read --allow-env --allow-run --allow-write.

πŸ“ Project Structure

Slick uses a strict, convention-based layout. The templates/, pages/ and static/ directories are required; the islands/ directory is optional.

project-root/
β”œβ”€β”€ pages/          # Route definitions      (required)
β”‚   β”œβ”€β”€ index.tsx
β”‚   └── about.tsx
β”œβ”€β”€ templates/      # Page shells            (required)
β”‚   β”œβ”€β”€ app.tsx
β”‚   └── admin.tsx
β”œβ”€β”€ islands/        # Interactive components (optional)
β”‚   └── Counter.tsx
└── static/         # Public assets          (required)
    β”œβ”€β”€ styles/
    β”‚   β”œβ”€β”€ reset.css
    β”‚   └── app.css
    β”œβ”€β”€ scripts/
    β”‚   └── app.ts
    └── assets/
        └── favicon.ico
Directory Required Description
pages/ βœ… Each file (.ts, .tsx, .js, .jsx) must export default a Page. Subdirectories are scanned.
templates/ βœ… Each file must export default a Template. Subdirectories are scanned.
static/ βœ… Served at the site root. CSS/JS/TS are transpiled and minified; everything else is served as-is.
islands/ ⬜ Each file must export default a Preact function component to be hydrated on the client.

Pages and templates are discovered recursively, so you can organize them in nested folders freely. Two pages cannot share a url, and two templates cannot share a name: Slick refuses to start.

βš™οΈ Configuration

The Slick constructor takes the workspace path and a partial configuration object:

new Slick(workspace: string, config: Partial<Config> = {})
interface Config {
	env: Record<string, string>; // Compile-time constants (SSR + client)
	port: number; // HTTP port
	lang: string; // <html lang="..."> attribute
	r404: string; // Fallback URL when no route/asset matches
	client: boolean; // SPA mode: bundles @webtools/slick-client from the project's deno.json
	hotReload: boolean; // Dev mode: reload pages, templates, islands and static assets on change
	trustProxy: boolean; // Trust X-Forwarded-For (behind exactly one reverse proxy)
	sharedLibs: readonly string[]; // Extra libraries made available to islands
}

Options

Option Type Default Description
env Record<string, string> {} Compile-time constants injected via esbuild define. Available in pages, templates, islands (SSR and client), static scripts and vendor bundles. See Environment Variables.
port number 5000 Port the HTTP server listens on.
lang string "en" Value of the HTML lang attribute.
r404 string "/" Page URL to redirect to when a route or asset is not found. Must match an existing page.
client boolean false When true, enables SPA navigation. @webtools/slick-client is bundled from the version in the project's deno.json imports and exposed through the import map.
hotReload boolean false When true, enables development hot reload. See Hot Reload.
trustProxy boolean false Reads the client IP from X-Forwarded-For (req.ip). Only enable it behind exactly one trusted reverse proxy.
sharedLibs readonly string[] [] Libraries bundled once globally and shared across all islands (avoids duplicating them in every island bundle). Merged with the built-in Preact libs.

πŸ”₯ Hot Reload

Set hotReload: true during development to pick up file changes without restarting the server.

const app = new Slick(import.meta.dirname!, {
	hotReload: Deno.env.get("DENO_ENV") !== "production",
});

Slick watches the whole workspace (except static/, node_modules/ and hidden folders). When a source file is created, modified or removed, Slick invalidates it and every module that imports it, then reloads the pages, templates and islands concerned and rebuilds the routes at once.

What changes What happens
A page or a template It is reloaded. A page with a new url gets its route, a removed file loses its route.
An island It is reloaded and its client bundle rebuilt; the pages and templates importing it are reloaded.
A shared module (utils, components…) Every page, template and island importing it, directly or not, is reloaded.
A file in static/ Recompiled on every request (no cache, no ETag).
A broken file (syntax error…) The error is logged and the previous version stays served, nothing is half-applied.

Changes are visible on the next HTTP request (refresh the browser to re-hydrate islands).

Restart still required when you change config.env, port, sharedLibs or other server options, or when you create the islands/ directory for the first time.

πŸ”‘ Environment Variables

Keys from config.env are injected at compile time via esbuild define. Reference them as bare global identifiers β€” no process.env, no Deno.env:

const app = new Slick(import.meta.dirname!, {
	env: {
		API_URL: Deno.env.get("API_URL") ?? "http://localhost:3000",
	},
});

They are available everywhere Slick compiles your code, including during SSR:

Surface SSR Client (browser)
Pages βœ… β€”
Templates βœ… β€”
Islands βœ… βœ…
Static .js / .ts β€” βœ…
Vendor bundles β€” βœ…
// pages/index.tsx β€” evaluated at SSR
export default {
	url: "/",
	template: app,
	title: `Home β€” ${API_URL}`,
	body: <p>Connected to {API_URL}</p>,
} satisfies Page<typeof app>;
// islands/Status.tsx β€” SSR render + hydrated client bundle
export default function Status() {
	return <p>API: {API_URL}</p>;
}
// static/scripts/app.ts β€” served to the browser
console.log("API:", API_URL);

Values are baked in when Slick compiles a module (at startup, on hot reload, or when a static asset is served). Change config.env and restart the server to pick up new values.

🎨 Templates

A Template defines the structure shared across the pages that use it. Only name and body are required.

interface Template<TData extends object = {}> {
	name: string; // Unique template name
	favicon?: Render<string>; // Favicon URL (static or dynamic)
	styles?: readonly string[]; // Global stylesheet URLs
	scripts?: readonly string[]; // Global module script URLs
	head?: Render<VNode>; // Extra <head> content
	body: Render<VNode>; // Shell markup, must contain an element with id="app"
	onrequest?: Middleware<TData>; // Runs before every page using this template, fills req.data
}
import type { Template } from "@webtools/slick-server";

export default {
	name: "app",
	favicon: "/assets/favicon.ico",

	styles: ["/styles/reset.css", "/styles/global.css"],
	scripts: ["/scripts/global.ts"],

	head: (
		<>
			<meta name="theme-color" content="#000000" />
			<link rel="preconnect" href="https://fonts.googleapis.com" />
		</>
	),

	body: (
		<>
			<header>
				<nav>
					<a href="/">Home</a>
					<a href="/about">About</a>
				</nav>
			</header>
			<main id="app">{/* Page content injected here */}</main>
			<footer>
				<p>&copy; 2026 My App</p>
			</footer>
		</>
	),
} satisfies Template;

Required: the template body must contain exactly one element with id="app", written in the markup itself (not returned by a component). The page's body is injected as the children of that element. Slick throws an explicit error when it is missing.

πŸ“„ Pages

A Page defines a single route, the template it uses, and its content. Only url and template are required.

interface Page<TTemplate extends Template, TExtra = {}> {
	url: string; // Route path (must start with "/", ":params" are supported)
	template: TTemplate; // The template object, imported from templates/
	title?: Render<string>; // Document <title>
	styles?: readonly string[]; // Page-specific stylesheet URLs
	scripts?: readonly string[]; // Page-specific module script URLs
	head?: Render<VNode>; // Extra <head> content for this page
	body?: Render<VNode>; // Page content (injected into the template's #app)
	onpost?: RequestListener; // Handler for POST requests to this URL
	onrequest?: Middleware; // Runs after the template's onrequest, before rendering
}

The second type parameter is optional, see Typed Request Data.

Example

import type { Page } from "@webtools/slick-server";
import app from "../templates/app.tsx";

export default {
	url: "/about",
	template: app,
	title: "About Us",

	styles: ["/styles/pages/about.css"],
	scripts: ["/scripts/pages/about.ts"],

	head: <meta name="description" content="Learn more about us" />,
	body: (
		<>
			<h1>About Us</h1>
			<p>We are a great company!</p>
		</>
	),

	onpost: (req, res) => res.json({ success: true }),

	onrequest: (req, res) => {
		if (!req.headers.has("authorization")) return res.redirect("/login");
	},
} satisfies Page<typeof app>;

Middleware (onrequest)

onrequest runs before the page is rendered (and before onpost), first the template's (template.onrequest) then the page's (page.onrequest). Return a response (e.g. res.redirect(...)) to short-circuit; return nothing to continue.

POST handling (onpost)

onpost is called for POST requests to the page URL, with the middlewares above already applied (req.data is filled). Its req.body is parsed according to the content type. In client (SPA) mode, POST requests carrying the X-Slick-Template header are intercepted by Slick to perform SPA navigation, so use onpost for your own form submissions and API endpoints on standard POST requests. Without onpost, such a request answers 405 in client mode and 404 otherwise.

🧩 Typed Request Data

Slick uses the typed middleware of @webtools/expressapi. What a template's onrequest puts in req.data is typed in its own head, body and favicon, and in every page using it.

Declare the data in the template's type parameter:

// templates/app.tsx
import type { Template } from "@webtools/slick-server";

export default {
	name: "app",

	onrequest: async (req) => {
		req.data.user = await findUser(req.cookies.session);
	},

	body: (req) => (
		<>
			<header>{req.data.user?.name ?? "Guest"}</header>
			<main id="app" />
		</>
	),
} satisfies Template<{ user: User | null }>;

Pages import the template and pass its type: req.data.user is typed everywhere, with no cast.

// pages/dashboard.tsx
import type { Page } from "@webtools/slick-server";
import app from "../templates/app.tsx";

export default {
	url: "/dashboard",
	template: app,
	title: (req) => `Dashboard of ${req.data.user?.name}`,
	body: (req) => <h1>Hello {req.data.user?.name}</h1>,
} satisfies Page<typeof app>;

A page can add its own data, and type its params and query, with the second type parameter. Its onrequest can read what the template provided and fills the page data:

// pages/products.tsx
import type { Page } from "@webtools/slick-server";
import app from "../templates/app.tsx";

export default {
	url: "/products/:id",
	template: app,

	onrequest: async (req, res) => {
		const product = await findProduct(req.params.id);
		if (!product) return res.status(404).json({ success: false });

		req.data.product = product;
	},

	title: (req) => req.data.product.name,
	body: (req) => (
		<>
			<h1>{req.data.product.name}</h1>
			<p>Page {req.query.page ?? "1"}, seen by {req.data.user?.name}</p>
		</>
	),

	onpost: (req, res) => res.json({ id: req.params.id }),
} satisfies Page<
	typeof app,
	{
		data: { product: Product };
		params: { id: string };
		query: { page?: string };
	}
>;
Page second parameter Effect Default
data Added to the template's data in req.data Only the template's data
params Type of req.params Record<string, string>
query Type of req.query Record<string, string | undefined>

satisfies does not infer type parameters, so the second parameter is written by hand when you use it. The first one is always typeof the template you set in template: using another template is a compile error.

Inferring everything with template() and page()

If you would rather not write any type, wrap the module in template() / page(). They return their argument as is, and TypeScript infers the rest, the same way it does with middleware() in expressapi: the data comes from the middleware<TAdds, TNeeds>() you give to onrequest, and the params come from the literal url.

// templates/app.tsx
import { middleware, template } from "@webtools/slick-server";

export default template({
	name: "app",

	onrequest: middleware<{ user: User | null }>(async (req) => {
		req.data.user = await findUser(req.cookies.session);
	}),

	// `req.data.user` is typed here, with nothing written by hand
	body: (req) => (
		<>
			<header>{req.data.user?.name ?? "Guest"}</header>
			<main id="app" />
		</>
	),
});
// pages/products.tsx
import { middleware, page, type TemplateData } from "@webtools/slick-server";
import app from "../templates/app.tsx";

export default page({
	url: "/products/:id", // req.params is { id: string }
	template: app,

	// Needs the data of the template (second parameter), adds `product` (first parameter)
	onrequest: middleware<{ product: Product }, TemplateData<typeof app>>(async (req, res) => {
		const product = await findProduct(req.params.id);
		if (!product) return res.status(404).json({ success: false });

		req.data.product = product;
	}),

	title: (req) => req.data.product.name,
	body: (req) => <h1>{req.data.product.name} seen by {req.data.user?.name}</h1>,
});

Both styles produce the same types and can be mixed in one workspace. A typo in req.data or req.params, or a template that does not provide what a page reads, is a compile error either way.

πŸ” Dynamic Rendering

title, favicon, head and body accept a Render<T> value: either a static value, or a (possibly async) function of the request. The request is the typed HttpRequest of expressapi: req.path, req.params, req.query, req.cookies, req.headers, req.data...

import type { Page } from "@webtools/slick-server";
import app from "../templates/app.tsx";

export default {
	url: "/products/:id",
	template: app,
	title: (req) => `Product ${req.params.id}`,

	body: async (req) => {
		const product = await fetchProduct(req.params.id);
		return (
			<>
				<h1>{product.name}</h1>
				<p>{product.description}</p>
				<p>Price: ${product.price}</p>
			</>
		);
	},
} satisfies Page<typeof app>;

πŸ“¦ Static Files

Everything inside static/ is served from the site root (e.g. static/styles/app.css β†’ /styles/app.css). Paths are URL-decoded, and anything resolving outside static/ is refused.

File type Handling
.css Minified, served as text/css.
.js / .ts / .jsx / .tsx Transpiled (TS/JSX β†’ JS), minified, served as ES modules (text/javascript).
Everything else Served as-is (images, fonts, JSON, etc.).

Every static response carries an ETag and Cache-Control: no-cache: browsers revalidate and get a 304 when nothing changed. Compiled CSS/JS/TS results are cached in memory after the first request. With hotReload: true, static assets are recompiled on every request and never cached.

Static scripts also receive config.env values β€” see Environment Variables.

🏝️ Islands

Islands are interactive Preact components that are rendered on the server and hydrated on the client. Only islands ship JavaScript to the browser, the rest of the page stays static HTML. This keeps pages fast while still allowing rich interactivity exactly where you need it.

1. Create an island (islands/Counter.tsx)

Each island file must export default a Preact function component.

import { useSignal } from "@preact/signals";

interface Props {
	start: number;
	label?: string;
}

export default function Counter({ start, label = "Counter" }: Props) {
	const count = useSignal(start);

	return (
		<div style="display:flex;align-items:center;gap:12px">
			<strong>{label}</strong>
			<button type="button" onClick={() => count.value--}>βˆ’</button>
			<span>{count}</span>
			<button type="button" onClick={() => count.value++}>+</button>
		</div>
	);
}

2. Use it inside a page or template

Import the island like any component and use it in your JSX. Slick automatically detects it during SSR, tags it in the HTML, and hydrates it on the client.

import type { Page } from "@webtools/slick-server";
import Counter from "../islands/Counter.tsx";
import app from "../templates/app.tsx";

export default {
	url: "/",
	template: app,
	title: "Home",

	body: (
		<section>
			<h1>Interactive counters</h1>
			<Counter start={0} label="A" />
			<Counter start={100} label="B" />
		</section>
	),
} satisfies Page<typeof app>;

That's it, no manual registration, no extra script tags. The hydration runtime is injected automatically whenever at least one island exists, and it even hydrates islands added later via SPA navigation.

Rules

  • Props must be JSON-serializable. Island props are serialized on the server and parsed on the client to re-create the component. Plain data (numbers, strings, booleans, arrays, objects) works; functions, class instances and children do not survive hydration (children is not sent).
  • An island must render at least one root element. Several root elements are fine (a Fragment of elements is hydrated as one island), but an island that renders nothing (null) or only text throws an explicit error: there would be nothing to hydrate. Do not mix text with elements at the root.
  • Islands can use other islands. An island imported by another island is just a regular component of its parent: only the outermost island is hydrated, and the inner one is part of its bundle.

How islands work under the hood

  • During SSR, a Preact hook tags every root element of an island with data-slick-island (its file name) and data-slick-island-id (one id per island instance); the first one also gets data-slick-props (its serialized props). An island rendered inside another island is not tagged.
  • A tiny hydration script scans the DOM, groups the tagged elements by id, lazily imports each island from /~islands/<name>, and hydrates it in place with Preact.
  • A MutationObserver re-runs hydration when new islands appear (e.g. after SPA navigation).
  • Each island is bundled individually with esbuild; shared libraries are kept external and loaded once (see below).

πŸ“š Shared Libraries & Vendors

Each island is bundled individually with esbuild. By default, esbuild inlines every dependency an island imports directly into that island's bundle. So if three islands all import chart.js, the entire chart.js library gets duplicated three times once in each bundle, and the browser downloads and parses it three times.

sharedLibs solves this. A shared library is bundled once, globally, into a standalone vendor bundle served from /~vendors/<lib>. It is then kept external to every island bundle: instead of inlining the code, islands simply import it, and a generated <script type="importmap"> redirects that import to the single shared vendor bundle. The result: each shared library is downloaded, parsed and cached once, no matter how many islands use it.

Without sharedLibs                         With sharedLibs
─────────────────                          ───────────────
island A [ chart.js ⨉ full copy ]        island A ─┐
island B [ chart.js ⨉ full copy ]        island B ─┼─→ import map ─→ /~vendors/chart.js  (one copy)
island C [ chart.js ⨉ full copy ]        island C β”€β”˜

Libraries bundled by default

When the islands/ directory exists, the Preact ecosystem is always treated as shared, so these are available to every island out of the box (you don't need to add them to sharedLibs):

  • preact
  • preact/hooks
  • preact/jsx-runtime
  • preact-root-fragment (the version in your deno.json imports; required as soon as islands/ exists)
  • @preact/signals (only if it is in your deno.json imports, otherwise it is skipped)

With client: true, @webtools/slick-client is also bundled, from the version in your deno.json imports, and served from /~vendors/@webtools/slick-client.

sharedLibs is merged with this built-in list, so anything you declare is added on top of the defaults. Without an islands/ directory and without client, nothing is bundled and no import map is emitted unless you declare sharedLibs.

Rule of thumb: any third-party library imported by more than one island (or any large library imported even once) should be added to sharedLibs to avoid duplicating it across bundles.

To use any other npm/JSR package inside an island, add it in two places:

  1. The imports of your project's deno.json (so Deno and esbuild can resolve it).
  2. The sharedLibs config option (so Slick exposes it through the import map).

Slick resolves imports from the closest deno.json above the workspace directory, and from the Deno workspace it belongs to. In a monorepo you can therefore declare the libraries in the deno.json of the project that owns the Slick workspace: there is no need to repeat them in the root deno.json, nor in the one of the directory you run deno from.

// server.ts
const app = new Slick(import.meta.dirname!, {
	client: true,
	sharedLibs: ["chart.js", "canvas-confetti", "marked"],
});

await app.start();
// deno.json
{
	"imports": {
		"@webtools/slick-server": "jsr:@webtools/slick-server@^0.7.0",
		"chart.js": "npm:chart.js@^4.5.1",
		"canvas-confetti": "npm:canvas-confetti@^1.9.4",
		"marked": "npm:marked@^18.0.5"
	},
	"compilerOptions": {
		"jsxImportSource": "preact",
		"jsx": "react-jsx"
	}
}

You can now import those libraries directly in your islands:

// islands/BarChart.tsx
import { useEffect, useRef } from "preact/hooks";
import { BarController, BarElement, CategoryScale, Chart, LinearScale } from "chart.js";

Chart.register(BarController, BarElement, CategoryScale, LinearScale);

export default function BarChart({ labels, values }: { labels: string[]; values: number[] }) {
	const canvas = useRef<HTMLCanvasElement>(null);

	useEffect(() => {
		if (!canvas.current) return;
		const chart = new Chart(canvas.current, {
			type: "bar",
			data: { labels, datasets: [{ data: values }] },
		});
		return () => chart.destroy();
	}, []);

	return <canvas ref={canvas} />;
}

πŸ”„ Client Mode (SPA)

When client is set, Slick turns your multi-page app into a single-page app without giving up SSR:

  • The first request of every page is fully server-rendered (great for SEO and first paint).
  • Subsequent navigations are performed over fetch: the server returns a JSON payload describing only what changed (the page, and the template only if it differs), and the client patches the DOM.

Enabling it

const app = new Slick(import.meta.dirname!, { client: true });
await app.start();

Add @webtools/slick-client to the imports of the project's deno.json. Slick bundles that version; it does not pin one of its own.

"@webtools/slick-client": "jsr:@webtools/slick-client@^0.3.0"

How it works

  • With client: true, Slick bundles @webtools/slick-client (the version from that import) and exposes it through the generated import map (/~vendors/@webtools/slick-client).
  • The client initializes with the current template name and intercepts navigations.
  • On navigation, the server is asked (via the X-Slick-Template header) for a JSON payload containing the new page, and the template too, only when it changed, which the client applies in place. The template and page middlewares run as for a normal request. Navigating to an unknown page follows the redirect to r404.
  • styles and scripts marked with a slick-type attribute let the client swap page/template assets correctly between navigations.
  • The document keeps a <div id="root">, a <title> and a <link rel="shortcut icon"> (an empty data:, icon when the template has no favicon): the client relies on them.

🧭 Routing & Reserved Paths

For each registered page, Slick exposes:

  • GET <page.url> server-rendered HTML (or SPA JSON in client mode). HEAD is answered too.
  • POST <page.url> routed to onpost, or handled by Slick for SPA navigation when the X-Slick-Template header is present.

URLs support :params (typed in req.params); static segments win over params (/users/me before /users/:id).

The following path prefixes are reserved by Slick and should not be used for your own routes or static files:

Path Purpose
/~islands/* Per-island client bundles.
/~vendors/* Shared library (vendor) bundles for islands.

Bundles and static files are served with an ETag. Any GET/HEAD request that matches no page, reserved path, or static file is redirected to r404.

πŸ”Œ API Reference

Slick

class Slick {
	constructor(workspace: string, config?: Partial<Config>);
	load(): Promise<void>;
	start(): Promise<Deno.HttpServer<Deno.NetAddr>>;
	readonly fetch: (request: Request, info?: Deno.ServeHandlerInfo<Deno.NetAddr>) => Promise<Response>;
}

new Slick(workspace, config)

  • workspace Absolute path to your project root. Use import.meta.dirname!.
  • config A partial Config; any omitted option falls back to its default.

load(): Promise<void>

Validates the workspace, builds the vendor bundles, loads islands, templates and pages and registers all routes, without listening. It throws if:

  • the workspace or a required directory (templates/, static/, pages/) is missing,
  • a page or template module is invalid (wrong default export, missing url / name / body, a template that is not a template object),
  • two pages share a url, or two templates share a name,
  • the r404 URL does not match any page,
  • an island file does not export default a function component.

start(): Promise<Deno.HttpServer<Deno.NetAddr>>

Calls load(), starts the hot reload watcher if enabled, and listens on config.port. The returned server can be stopped with await server.shutdown().

const server = await app.start();

fetch(request, info?)

The request handler. Once load() was called, it can be used without opening a port, which makes the app easy to test:

const app = new Slick(import.meta.dirname!);
await app.load();

const response = await app.fetch(new Request("http://localhost/about"));

Exported types

Export Description
Slick The server class.
Config The configuration interface.
Page The shape of a default-exported page module.
Template The shape of a default-exported template module.
Render A static value or a function of the request.
SlickRequest The typed request received by renders and handlers, useful for components.
TemplateData The req.data a template provides: TemplateData<typeof app>.
UrlParams The :params of a literal URL: UrlParams<"/users/:id"> is { id: string }.
template Declares a template and infers its req.data from its onrequest.
page Declares a page and infers its data and params.
middleware Re-export of the typed middleware of expressapi.
import { type Config, middleware, page, Slick, type SlickRequest, template } from "@webtools/slick-server";

πŸ“ License

Distributed under the MIT License. See LICENSE for more information.

About

Slick is a small, simple, and ultrafast web framework built on Web Standards for Deno.

Topics

Resources

Stars

4 stars

Watchers

1 watching

Forks

Contributors

Languages