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 (+3 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 { gracileconst gracile: (config?: GracileConfig) => any[]
The main Vite plugin for loading the Gracile framework.
} from '@gracile/gracile/plugin';
export default defineConfigfunction defineConfig(config: UserConfig): UserConfig (+3 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[] | undefined
Array of vite plugins to use.
: [
gracilefunction gracile(config?: GracileConfig | undefined): any[]
The main Vite plugin for loading the Gracile framework.
({
This is the default, no need to set it up!
outputGracileConfig.output?: "static" | "server" | undefined
The target output for the build phase.
See the documentation.
: '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/server
folder contains an entry point to consume within your production application. - The
dist/client
folder 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 (+3 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 { gracileconst gracile: (config?: GracileConfig) => any[]
The main Vite plugin for loading the Gracile framework.
} from '@gracile/gracile/plugin';
export default defineConfigfunction defineConfig(config: UserConfig): UserConfig (+3 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[] | undefined
Array of vite plugins to use.
: [
gracilefunction gracile(config?: GracileConfig | undefined): any[]
The main Vite plugin for loading the Gracile framework.
({
outputGracileConfig.output?: "static" | "server" | undefined
The target output for the build phase.
See the documentation.
: 'server',
}),
],
});
Supported environments
- Express. By far, the most ubiquitous
node:http
based framework. - Hono. A pioneer among frameworks that use the
fetch
standard, while being compatible with a variety of JS runtimes. - Anything that can provide a
Request
in input and handle back aResponse
or anode:stream
Readable
.
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 Readable
s.
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(): core.Express
Creates 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(): Express
Creates 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(): core.Express
Creates 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"
.getClientBuildPathfunction getClientBuildPath(root: string): string
export getClientBuildPath
(import.meta.urlImportMeta.url: string
The absolute file:
URL of the module.
)));
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}`
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: (...handlers: RequestHandler<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>[]) => Express (+8 overloads)
(gracilemodule "@gracile/gracile/node"
.nodeAdapterfunction nodeAdapter(handler: GracileHandler, options?: NodeAdapterOptions): gracile.GracileNodeHandler
export nodeAdapter
(handlerimport handler
));
const serverconst server: Server<typeof IncomingMessage, typeof ServerResponse>
= appconst app: Express
.listenApplication<Record<string, any>>.listen(port: number, callback?: () => void): Server<typeof IncomingMessage, typeof ServerResponse> (+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"
.printUrlsfunction printUrls(server: string | AddressInfo | null): void
export printUrls
Pretty print your server instance address as soon as it is listening.
Matches the dev. server CLI output style.
(serverconst server: Server<typeof IncomingMessage, typeof ServerResponse>
.addressServer.address(): string | AddressInfo | null
Returns 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: `${string}-${string}-${string}-${string}-${string}`
: import('node:crypto').UUIDtype UUID = `${string}-${string}-${string}-${string}-${string}`
;
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) | undefined) => ServerType
} from '@hono/node-server';
import { serveStaticconst serveStatic: (options?: ServeStaticOptions) => MiddlewareHandler
} 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
<"*", "*", HandlerResponse<any>, {}, any>(path: "*", handler: H<any, "*", {}, HandlerResponse<any>>) => Hono<{
Variables: Gracile.Locals;
}, any, "/"> (+22 overloads)
(
'*',
serveStaticfunction serveStatic(options?: ServeStaticOptions): MiddlewareHandler
({ rootroot?: string | undefined
Root path, relative to current working directory from which the app was started. Absolute paths are not supported.
: gracilemodule "@gracile/gracile/hono"
.getClientBuildPathfunction getClientBuildPath(root: string): string
export getClientBuildPath
(import.meta.urlImportMeta.url: string
The absolute file:
URL of the module.
) }),
);
appconst app: Hono<{
Variables: Gracile.Locals;
}, any, "/">
.useHono<{ Variables: Gracile.Locals; }, any, "/">.use: MiddlewareHandlerInterface
<{
Variables: Gracile.Locals;
}>(...handlers: MiddlewareHandler<{
Variables: Gracile.Locals;
}, string, {}>[]) => Hono<{
Variables: Gracile.Locals;
}, any, "/"> (+20 overloads)
((cc: Context<{
Variables: Gracile.Locals;
}, string, {}>
, nextnext: Next
) => {
The Hono adapter will pick-up your locals (called "variables" by Hono) automatically.
cc: Context<{
Variables: Gracile.Locals;
}, string, {}>
.setContext<{ Variables: Gracile.Locals; }, string, {}>.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}`
Available only in secure contexts.
());
cc: Context<{
Variables: Gracile.Locals;
}, string, {}>
.setContext<{ Variables: Gracile.Locals; }, string, {}>.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
<{
Variables: Gracile.Locals;
}>(...handlers: MiddlewareHandler<{
Variables: Gracile.Locals;
}, string, {}>[]) => Hono<{
Variables: Gracile.Locals;
}, any, "/"> (+20 overloads)
(gracilemodule "@gracile/gracile/hono"
.honoAdapterfunction honoAdapter(handler: GracileHandler, options?: HonoAdapterOptions): gracile.GracileHonoHandler
export honoAdapter
(handlerimport handler
));
servefunction serve(options: Options, listeningListener?: ((info: AddressInfo) => void) | undefined): ServerType
(
{ fetchfetch: FetchCallback
: appconst app: Hono<{
Variables: Gracile.Locals;
}, any, "/">
.fetchHono<{ Variables: Gracile.Locals; }, any, "/">.fetch: (request: Request, Env?: unknown, executionCtx?: ExecutionContext | undefined) => 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"
.printUrlsfunction printUrls(server: string | AddressInfo | null): void
export printUrls
Pretty print your server instance address as soon as it is listening.
Matches the dev. server CLI output style.
(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: `${string}-${string}-${string}-${string}-${string}`
: import('node:crypto').UUIDtype UUID = `${string}-${string}-${string}-${string}-${string}`
;
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;