Modes: SPA, MPA, SSG, SSR, CSR
MPA / SSR is the base on which Gracile is built.
You can transform your project for two types of targets: “Server” and “Static”.
Static
Static-site generation (aka SSG) is technically ahead of time server-side
rendering (aka SSR).
You’ll get a portable artifact to deploy pretty much anywhere, with HTML
indexes, JS/CSS bundles and other static assets.
Server
Server side renders HTML pages on user request, provides API endpoints, serve static client assets and pre-rendered pages.
To fit nicely within your existing setup, Gracile builds an handler that you can consume within your app, with a dedicated or custom adapter.
The Gracile server handler/adapter combo can play nicely with WinterCG or Node HTTP-like runtime and frameworks.
Definitions
Single Page Application (SPA)
The Vite Lit SPA template, with npm create vite -t lit for example, is already
a great base for making highly dynamic, client web apps.
Keeping this in mind, you might want to upgrade to something a bit more featureful as soon as the project grows, without breaking your existing Vite setup.
That’s where Gracile comes into play: per-route splitting is a very powerful
feature that, in itself, is an incentive to use a full-stack framework.
Combined with CSR, view transitions, or just for serving well-separated parts of
your app, you’ll also get the folders to URL mapping convention, which has
proven to greatly benefit decent-sized projects/teams.
It’s not an “all or nothing” approach. Some leaves of your apps can be SPA-like,
others can be MPAs, with or without client-side routing augmentation.
You might even want to serve different, technically unrelated SPAs from a single
build/deployment.
SPAs are typically deployed from a static distributable.
With Gracile you’ll use the static output for that case.
Multiple Pages Application (MPA)
Every page will get pre-route assets splitting.
MPA can be static or server-side rendered, but unlike SPA they will do a full refresh on a route change, meaning you’ll have to maintain the client state in different ways (there are plenty!).
However, it’s possible to augment an MPA with Client-Side routing (see below).
Client-Side Routing (CSR)
Client-Side Routing in the case of an SPA, is entirely done from the bundled app running in the browser.
At first glance, this seems to be irreconcilable with the file-based, old-school web server’s way of handling routing tasks.
However, it’s possible to combine the best of both worlds:
- Robustness of a server-pre-generated HTML response.
- Snappiness of a JS-based page re-rendering
- SEO boons (yes, Google understands JS, but not all bots are running JS)
- More opportunities for fallbacks when something breaks into havoc
- Well-defined entry points for assets.
The last one is especially important when your app grows.
While it’s achievable to lazy load code and assets with CSR, the bundler will do
a better job with already smaller chunks as a starting point.
That’s what the per-route splitting strategy is providing.
Note
For now, the Gracile CSR add-on is under an
experimental phase.
It will work with both “Static” and “Server” modes.
Static mode (SSG)
This is the default output for Gracile the vite build phase.
📄 /vite.config.ts
import { defineConfigfunction defineConfig(config: UserConfig): UserConfig (+5 overloads)Type helper to make it easier to use vite.config.ts
accepts a direct
{@link
UserConfig
}
object, or a function that returns it.
The function receives a
{@link
ConfigEnv
}
object.
} from 'vite';
import { gracileimport gracile } from '@gracile/gracile/plugin';
export default defineConfigfunction defineConfig(config: UserConfig): UserConfig (+5 overloads)Type helper to make it easier to use vite.config.ts
accepts a direct
{@link
UserConfig
}
object, or a function that returns it.
The function receives a
{@link
ConfigEnv
}
object.
({
pluginsUserConfig.plugins?: PluginOption[] | undefinedArray of vite plugins to use.
: [
gracileimport gracile ({
This is the default, no need to set it up!
outputoutput: string : 'static',
}),
],
});
With a statically generated output, you’ll get access to those context
information: props and params.
Also, you get the chance to
populate the props via staticPaths
for dynamic route paths.
Server mode
During a server build phase, Gracile will output two distributables: server
and client.
- The
dist/serverfolder contains an entry point to consume within your production application. - The
dist/clientfolder contains the static files to serve, without fuss.
The server handler is provided to do upfront work before rendering the page and
outputting a Response. Then, you can use it via an adapter with your favorite
HTTP server framework.
First setup Vite:
📄 /vite.config.ts
import { defineConfigfunction defineConfig(config: UserConfig): UserConfig (+5 overloads)Type helper to make it easier to use vite.config.ts
accepts a direct
{@link
UserConfig
}
object, or a function that returns it.
The function receives a
{@link
ConfigEnv
}
object.
} from 'vite';
import { gracileimport gracile } from '@gracile/gracile/plugin';
export default defineConfigfunction defineConfig(config: UserConfig): UserConfig (+5 overloads)Type helper to make it easier to use vite.config.ts
accepts a direct
{@link
UserConfig
}
object, or a function that returns it.
The function receives a
{@link
ConfigEnv
}
object.
({
pluginsUserConfig.plugins?: PluginOption[] | undefinedArray of vite plugins to use.
: [
gracileimport gracile ({
outputoutput: string : 'server',
}),
],
});
Supported environments
- Express. By far, the most ubiquitous
node:httpbased framework. - Hono. A pioneer among frameworks that use the
fetchstandard, while being compatible with a variety of JS runtimes. - Anything that can provide a
Requestin input and handle back aResponseor anode:streamReadable.
The last one is possible by creating your very own adapter for your environment of choice.
Note
Under the hood, the Gracile Handler only require a standard Request input,
and returns a Response or a Node’s Readable. The Readable is used
because there isn’t widespread support for the standard
ReadableStream.from method at the moment. Also, for streaming usage,
node:http can only handle Readables.
For now, it would not make sense to do unwanted back and forth convertion,
hurting performance for unicity sake.
Express
This is a working base configuration for Express, see also the starter project for it.
📄 /server.js
import expressfunction express(): ExpressCreates an Express application. The express() function is a top-level function exported by the express module.
from 'express';
import * as gracilemodule "@gracile/gracile/node" from '@gracile/gracile/node';
import { handlerimport handler } from './dist/server/entrypoint.js';
const appconst app: Express = expressfunction express(): ExpressCreates an Express application. The express() function is a top-level function exported by the express module.
();
appconst app: Express .useApplication<Record<string, any>>.use: (...handlers: RequestHandler<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>[]) => Express (+8 overloads) (expressfunction express(): ExpressCreates an Express application. The express() function is a top-level function exported by the express module.
.staticvar e.static: serveStatic.RequestHandlerConstructor
(root: string, options?: serveStatic.ServeStaticOptions<express.Response<any, Record<string, any>>> | undefined) => serveStatic.RequestHandler<express.Response<any, Record<string, any>>>
This is a built-in middleware function in Express. It serves static files and is based on serve-static.
(gracilemodule "@gracile/gracile/node" .getClientBuildPath(import.meta.urlImportMeta.url: string )));
appconst app: Express .useApplication<Record<string, any>>.use: (...handlers: RequestHandler<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>[]) => Express (+8 overloads) ((reqreq: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>> , resres: Response<any, Record<string, any>, number> , nextnext: NextFunction ) => {
The Express adapter will pick-up your locals automatically.
resres: Response<any, Record<string, any>, number> .localsResponse<any, Record<string, any>, number>.locals: Record<string, any> & Locals .requestIdGracile.Locals.requestId: `${string}-${string}-${string}-${string}-${string}` = cryptovar crypto: Crypto .randomUUIDCrypto.randomUUID(): `${string}-${string}-${string}-${string}-${string}`The randomUUID() method of the Crypto interface is used to generate a v4 UUID using a cryptographically secure random number generator.
Available only in secure contexts.
();
resres: Response<any, Record<string, any>, number> .localsResponse<any, Record<string, any>, number>.locals: Record<string, any> & Locals .userEmailGracile.Locals.userEmail: string | null = reqreq: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>> .getRequest<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>.get(name: string): string | undefined (+1 overload)Return request header.
The Referrer header field is special-cased,
both Referrer and Referer are interchangeable.
Examples:
req.get('Content-Type');
// => "text/plain"
req.get('content-type');
// => "text/plain"
req.get('Something');
// => undefined
Aliased as req.header().
('x-forwarded-email') || null;
return nextnext: NextFunction
(err?: any) => void (+2 overloads)
();
});
appconst app: Express .useApplication<Record<string, any>>.use: <any, any, any, any, QueryString.ParsedQs, Record<string, any>>(path: any, ...handlers: RequestHandler<any, any, any, QueryString.ParsedQs, Record<string, any>>[]) => Express (+8 overloads) (gracilemodule "@gracile/gracile/node" .nodeAdapter(handlerimport handler ));
const serverconst server: Server<typeof IncomingMessage, typeof ServerResponse> = appconst app: Express .listenApplication<Record<string, any>>.listen(port: number, callback?: (error?: Error) => void): Server (+5 overloads)Listen for connections.
A node http.Server is returned, with this
application (which is a Function) as its
callback. If you wish to create both an HTTP
and HTTPS server you may do so with the "http"
and "https" modules as shown here:
var http = require('http')
, https = require('https')
, express = require('express')
, app = express();
http.createServer(app).listen(80);
https.createServer({ ... }, app).listen(443);
(
3030,
() => gracilemodule "@gracile/gracile/node" .printUrls(serverconst server: Server<typeof IncomingMessage, typeof ServerResponse> .addressServer.address(): AddressInfo | string | nullReturns the bound address, the address family name, and port of the server
as reported by the operating system if listening on an IP socket
(useful to find which port was assigned when getting an OS-assigned address):{ port: 12346, family: 'IPv4', address: '127.0.0.1' }.
For a server listening on a pipe or Unix domain socket, the name is returned
as a string.
const server = net.createServer((socket) => {
socket.end('goodbye\n');
}).on('error', (err) => {
// Handle errors here.
throw err;
});
// Grab an arbitrary unused port.
server.listen(() => {
console.log('opened server on', server.address());
});
server.address() returns null before the 'listening' event has been
emitted or after calling server.close().
()),
);
Locals with Express
📄 /src/ambient.d.ts
/// <reference types="@gracile/gracile/ambient" />
declare namespace Gracile {
interface Localsinterface Gracile.Locals {
requestIdGracile.Locals.requestId: any : import('node:crypto').UUID;
userEmailGracile.Locals.userEmail: string | null : string | null;
}
}
This is how you synchronise both Gracile and Express global Locals!
declare namespace Express {
You can do it in the other way, too; Gracile extending Express.
interface Localsinterface Express.Locals extends Gracile.Localsinterface Gracile.Locals {}
}
Hono
This is a working base configuration for Hono, see also the starter project for it.
📄 /server.js
import { Honoclass Hono<E extends Env = BlankEnv, S extends Schema = BlankSchema, BasePath extends string = "/">The Hono class extends the functionality of the HonoBase class.
It sets up routing and allows for custom options to be passed.
} from 'hono';
import { serveconst serve: (options: Options, listeningListener?: (info: AddressInfo) => void) => ServerType } from '@hono/node-server';
import { serveStaticconst serveStatic: <E extends Env = any>(options?: ServeStaticOptions<E>) => MiddlewareHandler<E> } from '@hono/node-server/serve-static';
import * as gracilemodule "@gracile/gracile/hono" from '@gracile/gracile/hono';
import { handlerimport handler } from './dist/server/entrypoint.js';
This is how you synchronise both Gracile and Hono global Variables!
/** @type {Hono<{ Variables: Gracile.Locals }>} */
const appconst app: Hono<{
Variables: Gracile.Locals;
}, any, "/">
= new Hononew Hono<{
Variables: Gracile.Locals;
}, any, "/">(options?: HonoOptions<{
Variables: Gracile.Locals;
}> | undefined): Hono<{
Variables: Gracile.Locals;
}, any, "/">
Creates an instance of the Hono class.
();
appconst app: Hono<{
Variables: Gracile.Locals;
}, any, "/">
.getHono<{ Variables: Gracile.Locals; }, any, "/", "/">.get: HandlerInterface
<"*", "*", Response, {}, any>(path: "*", handler: H<any, "*", {}, Response>) => Hono<{
Variables: Gracile.Locals;
}, any, "/", "*"> (+22 overloads)
(
'*',
serveStaticserveStatic<any>(options?: ServeStaticOptions<any> | undefined): MiddlewareHandler<any> ({ rootroot?: string | undefinedRoot path, relative to current working directory from which the app was started. Absolute paths are not supported.
: gracilemodule "@gracile/gracile/hono" .getClientBuildPath(import.meta.urlImportMeta.url: string ) }),
);
appconst app: Hono<{
Variables: Gracile.Locals;
}, any, "/">
.useHono<{ Variables: Gracile.Locals; }, any, "/", "/">.use: MiddlewareHandlerInterface
<{
Variables: Gracile.Locals;
}>(...handlers: MiddlewareHandler<{
Variables: Gracile.Locals;
}, "*", {}, Response>[]) => Hono<{
Variables: Gracile.Locals;
}, any, "/", "*"> (+20 overloads)
((cc: Context<{
Variables: Gracile.Locals;
}, "*", {}>
, nextnext: Next ) => {
The Hono adapter will pick-up your locals (called "variables" by Hono) automatically.
cc: Context<{
Variables: Gracile.Locals;
}, "*", {}>
.setContext<{ Variables: Gracile.Locals; }, "*", {}>.set: Set
<"requestId">(key: "requestId", value: `${string}-${string}-${string}-${string}-${string}`) => void (+1 overload)
('requestId', cryptovar crypto: Crypto .randomUUIDCrypto.randomUUID(): `${string}-${string}-${string}-${string}-${string}`The randomUUID() method of the Crypto interface is used to generate a v4 UUID using a cryptographically secure random number generator.
Available only in secure contexts.
());
cc: Context<{
Variables: Gracile.Locals;
}, "*", {}>
.setContext<{ Variables: Gracile.Locals; }, "*", {}>.set: Set
<"userEmail">(key: "userEmail", value: string | null) => void (+1 overload)
('userEmail', 'admin@admin.home.arpa');
return nextnext: () => Promise<void> ();
});
appconst app: Hono<{
Variables: Gracile.Locals;
}, any, "/">
.useHono<{ Variables: Gracile.Locals; }, any, "/", "/">.use: MiddlewareHandlerInterface
<any, {
Variables: Gracile.Locals;
}>(path: any, ...handlers: MiddlewareHandler<{
Variables: Gracile.Locals;
}, any, {}, Response>[]) => Hono<{
Variables: Gracile.Locals;
}, any, "/", any> (+20 overloads)
(gracilemodule "@gracile/gracile/hono" .honoAdapter(handlerimport handler ));
servefunction serve(options: Options, listeningListener?: (info: AddressInfo) => void): ServerType (
{ fetchfetch: FetchCallback : appconst app: Hono<{
Variables: Gracile.Locals;
}, any, "/">
.fetchHono<{ Variables: Gracile.Locals; }, any, "/", "/">.fetch: (request: Request, Env?: unknown, executionCtx?: ExecutionContext) => Response | Promise<Response>.fetch() will be entry point of your app.
, portport?: number | undefined : 3030, hostnamehostname?: string | undefined : gracilemodule "@gracile/gracile/hono" .LOCALHOST },
(serverserver: AddressInfo ) => gracilemodule "@gracile/gracile/hono" .printUrls(serverserver: AddressInfo ),
);
Locals with Hono Variables
📄 /src/ambient.d.ts
/// <reference types="@gracile/gracile/ambient" />
declare namespace Gracile {
interface Localsinterface Gracile.Locals {
requestIdGracile.Locals.requestId: any : import('node:crypto').UUID;
userEmailGracile.Locals.userEmail: string | null : string | null;
}
}
📄 /server.ts
const appconst app: any = new Hono<{ Variablestype Variables: Gracile.Locals : Gracile.Localsinterface Gracile.Locals }>();
📄 /server.js
Use JSDoc
/** @type {Hono<{ Variables: Gracile.Locals }>} */
const appconst app: Hono<{
Variables: Gracile.Locals;
}>
= new Hono();
Creating an adapter
You can use Gracile with an HTTP server that works with either Node’s own http
IncomingMessage/ServerResponse or a framework that supports the standard
WHATWG Request/Response pair.
Note that Node Readable stream API support is required by your JS runtime.
For the most straightforward reference, check how the Hono adapter is made under the hood. It can be a good inspiration for making an adapter for another WHATWG-friendly runtime.
Alternatively, for Koa, Fastify etc., check out Gracile’s source code
for the node:http adapter. It should be remotely similar to an Express setup,
with minor modifications.
You can then open a PR if you’re willing to contribute back to the community.
Supported deployment environments
Please kindly note that for now, Lit SSR is officially supported on Node.js
APIs compatible runtimes, as well as “serverless” Vercel functions.
There is ongoing work for official support across more environments, like
WinterCG-compatible runtimes (Deno, Netlify, Cloudflare, Alibaba…) and
possibly more (Bun, Service Workers…).
ReadableStream implementations can vary (e.g. ReadableStream.from is not
available everywhere), but as soon as this API is more stable and featureful as
Node’s Readable, Gracile internals will adopt it for streamlining its
rendering phase.
Static assets (JS, CSS, pre-rendered pages…)
All you need to do is to serve the client/dist from the /* route path of
your HTTP framework static assets handler.
Be sure to catch those requests before the Gracile handler kicks in, like for any other public assets.
Ahead-of-time, pre-rendered pages follow the same convention as any static
assets.
The client/dist/my-route/index.html with be served to the /my-route URL path
name.
See also
prerender in defining routes.
If you have any doubts, see how static assets are handled in the starter projects.
Smart caching (advanced)
This is an advanced technique that requires more setup than Gracile owns pre-rendered page, but also offers many more possibilities:
Pre-rendered pages happen once (ahead of time), and it’s unaware of the future
context from which the request happens.
With smart caching strategies, you can rely on your infrastructure to handle
this in a dynamic, on-demand way.
Since the 2020s, ‘“Stale-While-Revalidate” and “Content Cache”
policies have become more widespread.
It’s now relatively straightforward to serve cached pages, efficiently with
Vercel, Netlify, CloudFlare or Nginx. Remix or Fresh are doing this approach
successfully.
Also combining prefetch on hover/focus on links can pre-trigger the necessary work and “revive” expired cache before the user gets access to the content; if it’s quick enough. You could probably shave ~300ms with this.
Nginx web server
Use proxy_cache_background_update with Nginx
This is an example snippet. You should customize it further, like targeting mostly static routes, where data freshness is not critical (i.e., not on multi-page forms). And don’t forget that response headers may be used too, for granular and dynamic control.
proxy_cache_key $scheme://$host$uri$is_args$query_string;
proxy_cache_valid 200 10m;
proxy_cache_bypass $arg_should_bypass_cache_xyz1234;
proxy_cache_use_stale updating error timeout http_500 http_502 http_503 http_504 http_429;
proxy_cache_lock on;
proxy_cache_background_update on;