Defining routes
Like every full-stack meta-framework, routes are the central concept of
Gracile.
This is where everything got tied together, and besides that, and add-ons, there
aren’t many opinions left.
Gracile comes with a dedicated function that will take care of typings as with JS or TS, and nothing more.
Under the hood, it uses the
URLPattern API
which uses converted patterns like this:
/src/routes/foo/[param]/foo.ts→/foo/:param/foo/src/routes/bar/[...path]→/bar/:path*
Index
You have two ways to define an index for a folder.
- Using like:
foo/my-folder/index.ts - With parentheses like:
foo/(anything).ts,foo/(foo).ts…
Respectively:
/src/routes/foo/index.ts→/foo//src/routes/foo/(foo).ts→/foo/
The parentheses pattern is especially useful for quick file switching with your
IDE, where a lot of indexes can be confusing, same when debugging an error
trace.
Note that when indexes are noted that way, the first one will be chosen
(alphabetically).
Ignored files and directories
- Client-side sibling scripts (
<ROUTE>.client.{js,ts}) - Client-side sibling styles (
<ROUTE>.{css,scss,…}) - Leading underscore
_*.{js,ts},_my-dir/* - Leading dotfiles/directories (hidden on OS).
defineRoute parameters
The defineRoute provides a type-safe API that can be used with JavaScript or
TypeScript.
It’s analog to how numerous OSS projects are providing their configuration API
(like Vite’s defineConfig).
document
Provides the base document for the
route’s page template.
Given a pre-existing document, you’ll import it like this in your route
configuration:
📄 /src/document.ts
import { htmlimport html } from '@gracile/gracile/server-html';
export const documentconst document: (props: {
url: URL;
}) => any
= (propsprops: {
url: URL;
}
: { urlurl: URL : URL }) => htmlimport html `
<html>
<head>
<!-- ... -->
<title>${propsprops: {
url: URL;
}
.urlurl: URL .pathnameURL.pathname: stringThe pathname property of the URL interface represents a location in a hierarchical structure. It is a string constructed from a list of path segments, each of which is prefixed by a / character.
}</title>
</head>
<body>
<route-template-outlet></route-template-outlet>
</body>
</html>
`;
📄 /src/routes/my-page.ts
import { htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
} from 'lit';
import { defineRouteimport defineRoute } from '@gracile/gracile/route';
import { documentconst document: (props: {
url: URL;
}) => any
} from '../document.js';
export default defineRouteimport defineRoute ({
documentdocument: (context: any) => any : (contextcontext: any ) => documentfunction document(props: {
url: URL;
}): any
(contextcontext: any ),
templatetemplate: () => TemplateResult<1> : () => htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
`...`,
});
template
Provides a server-renderable template.
When combined with an enclosing document, we’ll call it a “Page”.
When used alone, we’ll call it an HTML “Fragment”.
📄 /src/routes/my-page.ts
import { htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
} from 'lit';
import { defineRouteimport defineRoute } from '@gracile/gracile/route';
export default defineRouteimport defineRoute ({
// ...
templatetemplate: (context: any) => TemplateResult<1> : (contextcontext: any ) => htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
`
<main>
<article class="prose">Hello</article>
</main>
`,
});
staticPaths
Used with static mode only.
You can provide props and params for populating page data.
template and document contexts will be properly typed thanks to the
staticPaths function return signature.
Hover context.props and context.params to see!
📄 /src/routes/[...path].ts
import { htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
} from 'lit';
import { defineRouteimport defineRoute } from '@gracile/gracile/route';
import { documentconst document: (props: {
url: URL;
}) => any
} from '../document.js';
export default defineRouteimport defineRoute ({
staticPathsstaticPaths: () => readonly [{
readonly params: {
readonly path: "my/first-cat";
};
readonly props: {
readonly cat: "Kino";
};
}, {
readonly params: {
readonly path: "my/second-cat";
};
readonly props: {
readonly cat: "Valentine";
};
}]
: () =>
[
{
paramsparams: {
readonly path: "my/first-cat";
}
: { pathpath: "my/first-cat" : 'my/first-cat' },
propsprops: {
readonly cat: "Kino";
}
: { catcat: "Kino" : 'Kino' },
},
{
paramsparams: {
readonly path: "my/second-cat";
}
: { pathpath: "my/second-cat" : 'my/second-cat' },
propsprops: {
readonly cat: "Valentine";
}
: { catcat: "Valentine" : 'Valentine' },
},
] as consttype const = readonly [{
readonly params: {
readonly path: "my/first-cat";
};
readonly props: {
readonly cat: "Kino";
};
}, {
readonly params: {
readonly path: "my/second-cat";
};
readonly props: {
readonly cat: "Valentine";
};
}]
,
documentdocument: (context: any) => any : (contextcontext: any ) => documentfunction document(props: {
url: URL;
}): any
({ ...contextcontext: any , titletitle: any : contextcontext: any .props.cat }),
Hover the tokens to see the typings reflection.
templatetemplate: (context: any) => Promise<TemplateResult<1>> : async (contextcontext: any ) => htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
`
<h1>${contextcontext: any .props.cat}</h1>
<main>${contextcontext: any .url.pathname}</main>
<footer>${contextcontext: any .params.path}</footer>
`,
});
prerender
For server output only.
Will generate a full HTML file as if it was generated from the static
output mode.
Useful for pages that don’t need to be dynamic on the server side (e.g.,
contact, docs, about…).
📄 /src/routes/about.ts
import { htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
} from 'lit';
import { defineRouteimport defineRoute } from '@gracile/gracile/route';
import { documentconst document: (props: {
url: URL;
}) => any
} from '../document.js';
export default defineRouteimport defineRoute ({
prerenderprerender: boolean : true,
documentdocument: (context: any) => any : (contextcontext: any ) => documentfunction document(props: {
url: URL;
}): any
(contextcontext: any ),
templatetemplate: (context: any) => Promise<TemplateResult<1>> : async (contextcontext: any ) => htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
` <h1>I will be prerendered!</h1> `,
});
handler
Used with server mode
only.
Like staticPaths, handler is a provider for props and can receive the
current — matched route — params.
There are two behaviors for the handlers:
-
Returning an instance of
Responsewill terminate the pipeline, without going through thetemplaterendering that happens afterward otherwise.
Useful for redirects, pure JSON API routes… -
Returning anything else will provide the typed
propsfor thetemplateto consume.
Minimal example:
📄 /src/routes/index.ts
import { htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
} from 'lit';
import { defineRouteimport defineRoute } from '@gracile/gracile/route';
import { documentconst document: () => any } from '../document.js';
const achievementsconst achievements: {
name: string;
}[]
= [{ namename: string : 'initial' }];
export default defineRouteimport defineRoute ({
handlerhandler: {
POST: (context: any) => Promise<Response>;
}
: {
POSTtype POST: (context: any) => Promise<Response> : async (contextcontext: any ) => {
const formDataconst formData: any = await contextcontext: any .request.formData();
const nameconst name: any = formDataconst formData: any .get('achievement')?.toString();
if (nameconst name: any ) achievementsconst achievements: {
name: string;
}[]
.pushArray<{ name: string; }>.push(...items: {
name: string;
}[]): number
Appends new elements to the end of an array, and returns the new length of the array.
({ namename: string });
return Responsevar Response: {
new (body?: BodyInit | null, init?: ResponseInit): Response;
prototype: Response;
error(): Response;
json(data: any, init?: ResponseInit): Response;
redirect(url: string | URL, status?: number): Response;
}
The Response interface of the Fetch API represents the response to a request.
.redirectfunction redirect(url: string | URL, status?: number): ResponseThe redirect() static method of the Response interface returns a Response resulting in a redirect to the specified URL.
(contextcontext: any .url, 303);
},
},
documentdocument: (context: any) => any : (contextcontext: any ) => documentfunction document(): any (contextcontext: any ),
templatetemplate: (context: any) => Promise<TemplateResult<1>> : async (contextcontext: any ) => htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
`
<form method="post">
<input type="text" name="achievement" />
<button>Add an "Achievement"</button>
</form>
<ul>
${achievementsconst achievements: {
name: string;
}[]
.mapArray<{ name: string; }>.map<TemplateResult<1>>(callbackfn: (value: {
name: string;
}, index: number, array: {
name: string;
}[]) => TemplateResult<1>, thisArg?: any): TemplateResult<1>[]
Calls a defined callback function on each element of an array, and returns an array that contains the results.
((achievementachievement: {
name: string;
}
) => htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
`<li>${achievementachievement: {
name: string;
}
.namename: string }</li>`)}
</ul>
`,
});
See also the “Forms” recipe for a full, contextualized example.
HTTP methods
Note that, per the HTML specs, only GET and POST can be used with an HTML
<form> element.
Other methods like DELETE, PUT, etc. can be used, but Gracile won’t pursue
the route template rendering with them.
A new method,
“QUERY”,
is also inside the radar, and will possibly be implemented in node:http and
other server environments.
Minimal example
📄 /src/routes/my-page.ts
import { htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
} from 'lit';
import { defineRouteimport defineRoute } from '@gracile/gracile/route';
import { documentconst document: (props: {
url: URL;
title: string;
}) => any
} from '../document.js';
export default defineRouteimport defineRoute ({
documentdocument: (context: any) => any : (contextcontext: any ) => documentfunction document(props: {
url: URL;
title: string;
}): any
({ ...contextcontext: any , titletitle: string : 'My Page' }),
templatetemplate: ({ url }: {
url: any;
}) => TemplateResult<1>
: ({ urlurl: any }) => htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
`
<main class="content">
<article class="prose">
<!-- ... -->
Hello ${urlurl: any .pathname}
<!-- ... -->
</article>
</main>
`,
});
Bare pages (for redirects, etc.)
Sometimes, you don’t want to bring a page template in a route, just a bare HTML
document, maybe with some <meta>; perfect use-case: page redirects.
It’s totally possible to skip the template altogether and just use a single,
server-only document.
Here, we will redirect the user to another URL, while collecting some analytics, all that with a nice and simple transitive screen:
📄 /src/routes/chat.ts
import { defineRouteimport defineRoute } from '@gracile/gracile/route';
import { htmlimport html } from '@gracile/gracile/server-html';
import discordLogoconst discordLogo: string from '../assets/icons/discord.svg';
import { DISCORD_INVITE_URLconst DISCORD_INVITE_URL: "https://discord.gg/Q8nTZKZ9H4" } from '../content/global.js';
import { googleAnalyticsimport googleAnalytics } from '../document-helpers.js';
const waitTimeInSecondsconst waitTimeInSeconds: 2 = 2;
export default defineRouteimport defineRoute ({
documentdocument: () => any : () => htmlimport html `
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
${googleAnalyticsimport googleAnalytics }
<style>
& {
font-family: system-ui;
color-scheme: dark light;
/* ... */
}
</style>
<title>Gracile - Discord Server (redirecting…)</title>
The current page, "https://gracile.js.org/chat/", will be forgotten from history after the redirection.
<meta
http-equiv="refresh"
content=${`${waitTimeInSecondsconst waitTimeInSeconds: 2 };URL=${DISCORD_INVITE_URLconst DISCORD_INVITE_URL: "https://discord.gg/Q8nTZKZ9H4" }`}
/>
</head>
<body>
${discordLogoconst discordLogo: string }
<p>Redirecting to the Discord invitation link…</p>
No need for the <route-template-outlet> here!
</body>
</html>
`,
});
What to put in routes?
Routes are the most basic unit of interaction with your user.
This is where you should do data fetching, and dispatch them to “components”, “templates”, “modules”, “features” or whatever conventions you choose to represent this data.
It’s generally better to use routes as entry points and not put too much UI or
logic in there, besides what’s strictly needed to bootstrap the page and forward
the context to components.
Routes are kind of “magic”, in the sense that you’re not calling them yourself
in your code, but the framework will use them predictably. Thankfully, Gracile
isn’t crowding top level module exports, but just the default one, with the
defineRoute helper.
While it adds a level of indentation (versus a top level export), it avoids
clashes with your module-scoped functions.
This is a perfectly reasonable use of ESM default exports.
No static analysis or extraction either, meaning your functions are not in silos
and won’t behave in unexpected ways due to custom pre-processing, which is very
common in other frameworks.
Client-side routing
For now, Gracile doesn’t provide any CSR mechanism out of the box.
Note that the Metadata add-on provides a
viewTransition option that will make this browser native feature quickly
available to you (it is just a meta tag), but it’s not supported outside
Blink-based browsers. It can be a nice progressive enhancement though, but not
quite the SPA feel you could get with user-land solutions.
Fortunately, there are plenty of options regarding CSR in the Lit ecosystem:
- Lit’s router
- Nano Stores Router with Nano Store Lit
- Navigo
- thepassle’s app-tools router
- Vaadin router
- micromorph
You might want to try DOM-diffing libraries, too.
Miscellaneous
Trailing slashes
For simplicity and predictability, Gracile is only supporting routes that end
with a slash.
This is for pages and server endpoints, not assets with file extensions.
Flexibility for the user will be added at one point, but this requires significant implementation work and testing, so this is not in the scope yet.
[!NOTE] The explanation below is extracted from the Rocket web framework documentation.
Below is a summary of investigations by Zach Leatherman and Sebastien Lorber
Legend:
- 🆘 HTTP 404 Error
- 💔 Potentially Broken Assets (e.g.,
<img src="image.avif">) - 🟡 SEO Warning: Multiple endpoints for the same content
- ✅ Correct, canonical or redirects to canonical
- ➡️ Redirects to canonical
about.html |
about/index.html |
|||
|---|---|---|---|---|
| Host | /about |
/about/ |
/about |
/about/ |
| GitHub Pages | ✅ | 🆘 404 |
➡️ /about/ |
✅ |
| Netlify | ✅ | ➡️ /about |
➡️ /about/ |
✅ |
| Vercel | 🆘 404 |
🆘 404 |
🟡💔 | ✅ |
| Cloudflare Pages | ✅ | ➡️ /about |
➡️ /about/ |
✅ |
| Render | ✅ | 🟡💔 | 🟡💔 | ✅ |
| Azure Static Web Apps | ✅ | 🆘 404 |
🟡💔 | ✅ |
If you wanna know more be sure to checkout Trailing Slashes on URLs: Contentious or Settled?.