SMF - Build Your Own Metaframework with Vite
Recently, in one of my tweets, I claimed that building a metaframework (a.k.a. a Next.js clone 😅) with Vite is easy. This article is an attempt to prove that somewhat bold claim by creating a reasonably usable Preact1 metaframework called SMF. SMF may stand for either "Simple MetaFramework" or the name of the fan club of Twisted Sister, my favorite band in my teenage years. You decide.
In this article, I will be assuming that you are reasonably familiar with Preact, Vite, and the concept of server-side rendering (SSR). I will use pnpm
but it's not a requirement. You can use npm
or yarn
if you prefer.
01. Project setup
I started with the following files:
.gitignore
node_modules
dist
package.json
{
"name": "smf",
"private": true,
"type": "module"
}
tsconfig.json
{
"compilerOptions": {
"target": "es2022",
"module": "ESNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
I also copied my .prettierrc
from another project. Omitted here for brevity.
Then I installed the dependencies.
pnpm i -S preact preact-render-to-string
pnpm i -D prettier vite @preact/preset-vite @types/node
Since we're using a single repo, the code for SMF and the code for the example app will live side by side. The src
directory will contain the example app and the smf
directory will contain the code for SMF. SMF will be a set of Vite plugins. Here's the initial version:
smf/smf.ts
import type { Plugin } from "vite";
export function smf(): Plugin[] {
// A list of plugins, empty for now
return [];
}
And the initial Vite config:
vite.config.ts
import { defineConfig } from "vite";
import { smf } from "./smf";
import { preact } from "@preact/preset-vite";
export default defineConfig({
plugins: [smf(), preact()],
});
Finally, I added a dev
script to package.json
:
"type": "module",
+ "scripts": {
+ "dev": "vite"
+ },
"dependencies": {
Now we can run pnpm dev
and see that Vite is working. It will start serving our "app" on localhost:5173
but you'll get a 404 since we haven't created an app yet.
✅ Checkpoint: You can find the progress so far in the
chapter-01
tag.
ssrLoadModule
to run server code
02. Using Vite's own SSR guide recommends using Vite in "middleware mode". If we followed it, we would use vite.createServer
with { server: { middlewareMode: true } }
to create a Vite development middleware, then create a Node server with, e.g. Express, and add the Vite middleware to it. Then we would use viteDevServer.ssrLoadModule
to load our server code.
This flow poses a chicken-and-egg problem if we want to use TypeScript or JSX: We need Vite's dev server to compile our server code but we need the server code to create the Vite middleware. We could use a separate tool like ts-node
but to me, it doesn't make sense to use a separate tool when Vite is already there. This approach also requires us to give up on Vite's CLI and handle all options ourselves. That's not ideal.
To solve this, we will take the recommended flow and turn it around: Instead of using Vite as a middleware in our server, we will use our server code as a middleware in Vite's dev server. Vite plugins can define a configureServer
hook to hook into Vite's dev server. We will use it to load our server code with the ssrLoadModule
method of the Vite dev server and inject it as a middleware. Vite uses connect
under the hood, you can think of it as Express minus the router. We can add a middleware with server.middlewares.use
just like adding an Express middleware. Here's how, read the comments for details:
smf/smf.ts
import type { Plugin } from "vite";
export function smf(): Plugin[] {
return [
{
name: "smf/load-handler",
enforce: "post",
apply: "serve",
configureServer(server) {
// Instead of adding the middleware here, we return a function that Vite
// will call after adding its own middlewares. We want our code to run after
// Vite's transform middleware so that we can focus on handling the requests
// we're interested in.
return () => {
server.middlewares.use(async (req, res, next) => {
try {
const handlerModule = await server.ssrLoadModule(
// This is where we'll put our handler
server.config.root + "/src/entry-handler.ts",
{
// This is required to make errors thrown in the handler
// to have the correct stack trace.
fixStacktrace: true,
},
);
await handlerModule.default(req, res);
// We're not calling `next` because our handler will always be
// the last one in the chain. If it didn't send a response, we
// will treat it as an error since there will be no one else to
// handle it in production.
if (!res.writableEnded) {
next(new Error("SSR handler didn't send a response"));
}
} catch (error) {
// Forward the error to Vite
next(error);
}
});
};
},
},
];
}
And our first handler will look like this:
src/entry-handler.ts
import type { RequestListener } from "node:http";
const handler: RequestListener = async (req, res) => {
// res.send is not available in plain Node.js, it's added by Express.
// We have to use res.write and/or res.end instead.
res.end(`Received a ${req.method} request to ${req.url}.`);
};
export default handler;
If you run pnpm dev
now, you should see the following output in the browser:
Received a GET request to /index.html.
That's weird. We requested /
but Vite rewrote it to /index.html
when its transform middleware couldn't find anything to serve. We don't want that, we need to set Vite's appType
configuration option to "custom"
to tell Vite that we will be serving our own dynamically generated HTML, not an index.html
file from the file system. We could set it in vite.config.ts
but that file belongs to the user. We should do it in our plugin's config
hook instead so that when the user adds our plugin, they don't have to worry about it:
smf/smf.ts
apply: "serve",
+ config() {
+ return {
+ appType: "custom",
+ };
+ },
configureServer(server) {
Now visiting localhost:5173/
should give us the expected output:
Received a GET request to /.
If you change the handler code and refresh the page, you will see the changes reflected (we will cover automatic reloading and HMR later). It works because ssrLoadModule
will invalidate its cache when a file or its dependencies change. This is not exactly hot module replacement (HMR), which Vite doesn't support for server code yet, but it's similar in that it allows us to change the code and see the changes without restarting the server.
This exact "the app as middleware" trick is used by many Vite-based metaframeworks such as SvelteKit, Astro, SolidStart, and Qwik. My vavite package, a set of tools for developing and building server-side applications with Vite that Rakkas uses under the hood, makes it even easier and solves a bunch of other challenges we will face later. Ordinarily, I would start with vavite
and skip this section and several others but I promised a "from scratch" tutorial so here we are.
Some other metaframeworks (most notably Nuxt) use vite-node
which is currently part of the Vitest project but it is expected to be merged into Vite core in the future. It is more difficult to set up but it has additional features like customizable HMR behavior and the ability to run Vite's server and the app code in separate VM contexts or processes. Nevertheless, we will stick with our simpler approach that has been proven to be more than adequate by so many successful metaframeworks.
✅ Checkpoint: You can find the progress so far in the
chapter-02
tag.
03. API routes
This may come as a surprise that we're implementing API routes before page routes but API routes are a more fundamental concept if you think about it. A page route is just a special kind of API route that returns HTML.
At this point, we have a pretty neat setup for running server code with Vite. The default export of src/entry-handler.ts
is simply a Node HTTP request listener that all Node HTTP frameworks are ultimately based on: In Express, the app
itself is a request listener; in Koa, you can get one with app.callback()
; in Fastify, you can use (req, res) => fastify.server.emit("request", req, res)
to create one once the server is ready; and so on. So we are in a position to use any popular Node HTTP framework we want. But we won't, because "from scratch" means "from scratch" 😅.
We'll start by implementing a simple router. The router will map paths to API modules. API modules will export a set of functions named after HTTP methods and they will be called when a request with the corresponding method is received. The signature will be method(ctx: RequestContext): void | Promise<void>
where RequestContext
is a simple object with req
, res
, and params
properties. params
will hold dynamic path parameters.
Most Node server frameworks use a builder pattern for the router. You create a router and you add routes to it in an imperative manner. We'll use a simpler declarative router instead. Building an imperative one on top of it is trivial if we need it later. Most Node server frameworks use a variation of the /path/to/:param/*rest
syntax for route patterns. We'll use a slightly modified one because we want to support file system routing later but :
and *
are hard to use in file names on Linux and Mac, and flat-out forbidden on Windows. I'll go with a loosely Remix-inspired /path/to/$param/$$rest
syntax. The usage will look something like this:
export default buildHandler({
"/foo": () => import("./routes/foo"),
"/bar": () => import("./routes/bar"),
"/baz/$id": () => import("./routes/baz"),
"/qux/$$rest": () => import("./routes/qux"),
});
There are some neat TypeScript tricks to infer the type of params
object from the route pattern but we won't go into that here. Also, for simplicity, we will use a linear regexp search. I didn't test the following thoroughly but it seemed to work with a few cases I tried it with. Our MF is called SMF, so good enough for Rock'n'Roll 🤘.
This will be part of SMF's server runtime so we'll put it in smf/server.ts
:
smf/server.ts
import type { IncomingMessage, RequestListener, ServerResponse } from "http";
export type RequestHandler<P = Record<string, string>> = (
ctx: RequestContext<P>,
) => void | Promise<void>;
export interface RequestContext<P = Record<string, string>> {
req: IncomingMessage;
res: ServerResponse;
params: P;
}
export interface ApiModule<P = Record<string, string>> {
get?: RequestHandler<P>;
post?: RequestHandler<P>;
put?: RequestHandler<P>;
delete?: RequestHandler<P>;
patch?: RequestHandler<P>;
options?: RequestHandler<P>;
head?: RequestHandler<P>;
all?: RequestHandler<P>; // Fallback for all methods
}
export function buildHandler(
apiRoutes: Record<string, () => Promise<ApiModule>>,
): RequestListener {
// Convert into an array of [RegExp, () => Promise<ApiModule>] tuples
const routes = Object.entries(apiRoutes).map(
([path, importer]) => [patternToRegExp(path), importer] as const,
);
return async function handler(req, res) {
// These are typed as optional for some reason
const { url = "/", method = "GET" } = req;
// Remove query string and hash
const path = url.match(/^[^?#]*/)![0];
const match = routes.find(([pattern]) => pattern.exec(path));
if (!match) {
res.statusCode = 404;
res.end("Not found");
return;
}
const importer = match[1];
try {
const module = await importer();
const handler =
module[method.toLowerCase() as keyof ApiModule] ?? module.all;
if (!handler) {
// Look ma, I'm HTTP-ly correct!
res.statusCode = 405;
res.end("Method not allowed");
return;
}
const params = match[0].exec(path)?.groups ?? {};
await handler({ req, res, params });
} catch (error) {
console.error(error);
res.statusCode = 500;
res.end("Internal server error");
}
};
}
function patternToRegExp(path: string) {
return RegExp(
"^" +
path
.replace(/[\\^*+?.()|[\]{}]/g, (x) => `\\${x}`) // Escape special characters except $
.replace(/\$\$(\w+)$/, "(?<$1>.*)") // $$rest to named capture group
.replace(/\$(\w+)(\?)?(\.)?/g, "$2(?<$1>[^/]+)$2$3") + // $param to named capture groups
"/?$", // Optional trailing slash and end of string
);
}
Now let's create a couple of API route modules in src/routes
. I will use the .api.ts
convention for API routes, it will come in handy when we implement file system routing later. I prefer an explicit naming convention to exposing every file in a directory as a route.
src/routes/foo.api.ts
import type { RequestHandler } from "../../smf/server";
export const get: RequestHandler = ({ res }) => {
res.end("Hello from foo!");
};
src/routes/bar.api.ts
import type { RequestHandler } from "../../smf/server";
export const get: RequestHandler = ({ res }) => {
res.end("Hello from bar!");
};
Finally, let's update src/entry-handler.ts
to use our new router:
src/entry-handler.ts
import { buildHandler } from "../smf/server";
export default buildHandler({
"/foo": () => import("./routes/foo.api"),
"/bar": () => import("./routes/bar.api"),
});
If you run pnpm dev
now, you will get a 404 for /
because we don't have a handler for it yet. But /foo
and /bar
should work as expected. So here we go, we have API routes.
✅ Checkpoint: You can find the progress so far in the
chapter-03
tag.
04. File system routing
Developers have a love-hate relationship with file system routing. It's simple and intuitive at first but file names make a poor configuration language. As more and more features are added, you risk ending up with monstrosities like /foo/bar/@top-bar/(..)(..)[...slug].tsx
. Nevertheless, file system routing is one of the staples of metaframework design. So we'll implement it too but we'll take a hybrid approach: It will be strictly optional and the user will be able to modify automatically generated routes as they see fit.
Vite has a import.meta.glob
feature that allows us to implement file system routing with very little effort. The docs say:
const modules = import.meta.glob("./dir/*.js");The above will be transformed into the following:
// code produced by vite const modules = { "./dir/foo.js": () => import("./dir/foo.js"), "./dir/bar.js": () => import("./dir/bar.js"), };
This is the exact format our router expects. What a coincidence! 😄
First, we'll add the following to tsconfig.json
to have Vite-specific types (like the types for import.meta.glob
) available in our code:
"jsxImportSource": "preact",
+ "types": ["vite/client"]
}
}
Now we can add const apiRoutes = import.meta.glob("./routes/**/*.api.ts");
to src/entry-handler.ts
. According to the docs, the outcome will be the same as if we had written:
const apiRoutes = {
"./routes/foo.api.ts": () => import("./routes/foo.api.ts"),
"./routes/bar.api.ts": () => import("./routes/bar.api.ts"),
};
As far as I can remember Vite takes care of normalizing path separators to forward slashes on Windows. So all we need to do is trim the ./routes
prefix and the .api.ts
suffix to convert the file names into route patterns. We will also trim /index
from the end of the route patterns to make /foo.api.ts
and /foo/index.api.ts
equivalent (we need it at least for the root route!). We'll implement this in a prepareApiRoutes
function that we'll add to the bottom of smf/server.ts
. The types are lenient because import.meta.glob
is not strongly typed anyway:
smf/server.ts
export function prepareApiRoutes(
apiRoutes: Record<string, () => Promise<any>>,
): Record<string, () => Promise<ApiModule>> {
return Object.fromEntries(
Object.entries(apiRoutes).map(([path, importer]) => {
// This is a bit fragile as it doesn't allow different file extensions
// but again, good enough for Rock'n'Roll.
let pattern = path.slice("./routes".length, -".api.ts".length);
if (pattern.endsWith("/index")) {
pattern = pattern.slice(0, -"/index".length);
}
return [pattern, importer];
}),
);
}
We will also need to sort the routes so that more specific routes come first. When the user was adding routes manually, we could dump this responsibility on them like Express does but since the order of the routes is not under the user's control anymore, we'll have to do it ourselves. Unfortunately, there are no universally applicable rules here: Should /foo/bar
match /foo/$param
or /$param/bar
first? I'm inclined to say the former but it's more of an opinion than a fact. However, I'm sure everyone will agree that /foo/$a-$b/bar
should come before /foo/$a/bar
. I'll adapt a simplified version of the sorting rules used by Rakkas. Feel free to disagree and modify it to your liking:
function compareRoutePatterns(a: string, b: string): number {
// Non-catch-all routes first: /foo before /$$rest
const catchAll =
Number(a.match(/\$\$(\w+)$/)) - Number(b.match(/\$\$(\w+)$/));
if (catchAll) return catchAll;
// Split into segments
const aSegments = a.split("/");
const bSegments = b.split("/");
// Routes with fewer dynamic segments first: /foo/bar before /foo/$bar
const dynamicSegments =
aSegments.filter((segment) => segment.includes("$")).length -
bSegments.filter((segment) => segment.includes("$")).length;
if (dynamicSegments) return dynamicSegments;
// Routes with fewer segments first: /foo/bar before /foo/bar
const segments = aSegments.length - bSegments.length;
if (segments) return segments;
// Routes with earlier dynamic segments first: /foo/$bar before /$foo/bar
for (let i = 0; i < aSegments.length; i++) {
const aSegment = aSegments[i];
const bSegment = bSegments[i];
const dynamic =
Number(aSegment.includes("$")) - Number(bSegment.includes("$"));
if (dynamic) return dynamic;
// Routes with more dynamic subsegments at this position first: /foo/$a-$b before /foo/$a
const subsegments = aSegment.split("$").length - bSegment.split("$").length;
if (subsegments) return subsegments;
}
// Equal as far as we can tell
return 0;
}
Then we'll have to update the first few lines of buildHandler
to use our comparison function:
export function buildHandler(
apiRoutes: Record<string, () => Promise<ApiModule>>,
): RequestListener {
// Convert into an array of [RegExp, () => Promise<ApiModule>] tuples
const routes = Object.keys(apiRoutes)
.sort(compareRoutePatterns)
.map((pattern) => [patternToRegExp(pattern), apiRoutes[pattern]] as const);
// ...
Now we can use prepareApiRoutes
in src/entry-handler.ts
:
src/entry-handler.ts
import { buildHandler, prepareApiRoutes } from "../smf/server";
const apiRoutes = import.meta.glob("./routes/**/*.api.ts");
export default buildHandler(prepareApiRoutes(apiRoutes));
If you run pnpm dev
now, you will see that /foo
and /bar
still work. If you delete or rename one of them, it will be reflected in the app. That's it, we have file system routing! The user can change the contents of the apiRoutes
object before passing it to buildHandler
or simply not use it at all and define their own routes. Best of both worlds.
Although import.meta.glob
is pretty flexible, some Vite-based metaframeworks use a custom file system routing solution. Rakkas, for example, generates a set of virtual modules for extra flexibility. SvelteKit follows a similar approach. But some do use import.meta.glob
and it's a perfectly good solution for most use cases.
✅ Checkpoint: You can find the progress so far in the
chapter-04
tag.
05. Page routes
Finally, without further ado, we'll implement page routes. We'll use the .page.tsx
convention for page route modules and each page route module will default export a Preact component. We'll modify our buildHandler
function to accept a { apiRoutes, pageRoutes, Document, App }
object instead of just apiRoutes
. Document
and App
will be Preact components to render the HTML document and the layout around the page content respectively. The difference between Document
and App
is that Document
will only render static HTML while App
can be interactive.
Let's rename smf/server.ts
to smf/server.tsx
for starters so that we can use JSX in it. Then we'll add the following:
smf/server.tsx
// Somewhere near the top
import { type ComponentType, type ComponentChildren } from "preact";
import { render } from "preact-render-to-string";
// Just before `buildHandler`
export interface PageModule {
default: ComponentType;
}
export interface HandlerOptions {
apiRoutes: Record<string, () => Promise<ApiModule>>;
pageRoutes: Record<string, () => Promise<PageModule>>;
Document: ComponentType<DocumentProps>;
App: ComponentType<AppProps>;
}
export interface DocumentProps {
children?: ComponentChildren;
}
export interface AppProps {
children: ComponentChildren;
}
Then we'll update buildHandler
and add a renderPage
helper function right after it:
smf/server.tsx
export function buildHandler(options: HandlerOptions): RequestListener {
const pageRoutes = Object.keys(options.pageRoutes)
.sort(compareRoutePatterns)
.map(
(pattern) =>
[
patternToRegExp(pattern),
options.pageRoutes[pattern],
"page",
] as const,
);
const apiRoutes = Object.keys(options.apiRoutes)
.sort(compareRoutePatterns)
.map(
(pattern) =>
[patternToRegExp(pattern), options.apiRoutes[pattern], "api"] as const,
);
return async function handler(req, res) {
// These are typed as optional for some reason
const { url = "/", method = "GET" } = req;
// Remove query string and hash
const path = url.match(/^[^?#]*/)![0];
// Page routes before API routes
const match =
pageRoutes.find(([pattern]) => pattern.exec(path)) ??
apiRoutes.find(([pattern]) => pattern.exec(path));
if (!match) {
res.statusCode = 404;
res.end("Not found");
return;
}
try {
// Page or API?
if (match[2] === "page") {
const importer = match[1];
const module = await importer();
const html = renderPage(module.default, options.Document, options.App);
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(html);
return;
}
const importer = match[1];
const module = await importer();
const handler =
module[method.toLowerCase() as keyof ApiModule] ?? module.all;
if (!handler) {
// Look ma, I'm HTTP-ly correct!
res.statusCode = 405;
res.end("Method not allowed");
return;
}
const params = match[0].exec(path)?.groups ?? {};
await handler({ req, res, params });
} catch (error) {
console.error(error);
res.statusCode = 500;
res.end("Internal server error");
}
};
}
function renderPage(
Page: ComponentType,
Document: ComponentType<DocumentProps>,
App: ComponentType<AppProps>,
) {
const document = render(
<Document>
<App>
<Page />
</App>
</Document>,
);
return "<!DOCTYPE html>" + document;
}
We'll also need a preparePageRoutes
function, the page route equivalent of prepareApiRoutes
:
smf/server.tsx
export function preparePageRoutes(
pageRoutes: Record<string, () => Promise<any>>,
): Record<string, () => Promise<PageModule>> {
return Object.fromEntries(
Object.entries(pageRoutes).map(([path, importer]) => {
let pattern = path.slice("./routes".length, -".page.tsx".length);
if (pattern.endsWith("/index")) {
pattern = pattern.slice(0, -"/index".length);
}
return [pattern, importer];
}),
);
}
Then our src/App.tsx
and src/Document.tsx
will look like this:
src/App.tsx
import { AppProps } from "../smf/server";
export function App(props: AppProps) {
return <div>{props.children}</div>;
}
src/Document.tsx
import { DocumentProps } from "../smf/server";
export function Document(props: DocumentProps) {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>My SMF App</title>
</head>
<body>
<div id="app">{props.children}</div>
</body>
</html>
);
}
Then we'll update src/entry-handler.ts
to use our new buildHandler
:
src/entry-handler.ts
import {
buildHandler,
prepareApiRoutes,
preparePageRoutes,
} from "../smf/server";
import { Document } from "./Document";
import { App } from "./App";
const apiRoutes = prepareApiRoutes(import.meta.glob("./routes/**/*.api.ts"));
const pageRoutes = preparePageRoutes(
import.meta.glob("./routes/**/*.page.tsx"),
);
export default buildHandler({
apiRoutes,
pageRoutes,
Document,
App,
});
Finally, we'll create our first page route:
src/routes/index.page.tsx
export default function HomePage() {
return <h1>Hello, world!</h1>;
}
That was a lot of code but most of it was just boilerplate. If you run pnpm dev
now, you will finally see our first page rendered at localhost:5173/
. We're not sending any client-side JavaScript yet and we don't have a way to pass data to our pages but our router now supports both pages and API routes. Pretty cool, no?
✅ Checkpoint: You can find the progress so far in the
chapter-05
tag.
06. Hydration
We have a working SSR setup now but we need to send some client-side JavaScript and hydrate our page to make it interactive. First, let's update our home page to add interactivity:
src/routes/index.page.tsx
import { useState } from "preact/hooks";
export default function HomePage() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Hello, world!</h1>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
When you run pnpm dev
now, you will see that the page is not interactive. Let's make it interactive by first adding a script tag to src/Document.tsx
:
src/Document.tsx
<div id="app">{props.children}</div>
+ <script type="module" src="/src/entry-client.tsx" />
</body>
Then we'll need to create src/entry-client.tsx
that we just referenced:
src/entry-client.ts
alert("Hello from entry-client.tsx!");
If you run pnpm dev
now, you will see the alert. Yay, our first few bytes of client-side JavaScript! We're web developers so obviously we will keep adding more until our bundle size exceeds the mass of the observable universe. But we'll get there in small increments.
Since we have only one page, we know exactly what's going to be rendered on the client. Let's start with a simple hard-coded version. Note that we're not hydrating the whole Document
component, but just App
:
src/entry-client.tsx
import { hydrate } from "preact";
import { App } from "./App";
import HomePage from "./routes/index.page";
hydrate(
<App>
<HomePage />
</App>,
document.getElementById("app")!,
);
If you run pnpm dev
now, you will see that the page is now interactive!
Let's tackle HMR next. We'll just update our Document
component to inject Vite's client script:
src/Document.tsx
<title>My SMF App</title>
+ <script type="module" src="@vite/client" />
</head>
That's it! Refresh the page one last time and now Vite's client script and the Preact plugin will take care of the rest. Now you can update the index.page.tsx
file and the changes will be reflected without a full page reload. You can even click on the "Increment" button a few times and the counter will keep its state between refreshes. This comes in extremely handy when developing complex UIs.
Let's fix the hard-coded routing next. Since page routes need to be shared between the server and the client, we'll move them to src/page-routes.ts
page first:
src/page-routes.ts
import { preparePageRoutes } from "../smf/server";
export const pageRoutes = preparePageRoutes(
import.meta.glob("./routes/**/*.page.tsx"),
);
Our handler entry will now become:
src/entry-handler.ts
import { buildHandler, prepareApiRoutes } from "../smf/server";
import { Document } from "./Document";
import { App } from "./App";
import { pageRoutes } from "./page-routes";
const apiRoutes = prepareApiRoutes(import.meta.glob("./routes/**/*.api.ts"));
export default buildHandler({
apiRoutes,
pageRoutes,
Document,
App,
});
Then we'll start creating SMF's client runtime. But we should move the bits that will be shared between the client and the server into a smf/shared.ts
file first:
smf/shared.ts
import type { ComponentType, ComponentChildren } from "preact";
export interface PageModule {
// ...
}
export interface AppProps {
// ...
}
export function patternToRegExp(path: string) {
// ...
}
export function compareRoutePatterns(a: string, b: string): number {
// ...
}
We will of course delete them from smf/server.tsx
and import from smf/shared.ts
instead.
Now we're good to go with smf/client.tsx
:
smf/client.tsx
import { hydrate, type ComponentType } from "preact";
import {
patternToRegExp,
compareRoutePatterns,
type PageModule,
type AppProps,
} from "./shared";
export interface ClientOptions {
App: ComponentType<AppProps>;
pageRoutes: Record<string, () => Promise<PageModule>>;
}
export async function startClient(options: ClientOptions) {
const { App, pageRoutes } = options;
const routes = Object.keys(pageRoutes)
.sort(compareRoutePatterns)
.map((pattern) => [patternToRegExp(pattern), pageRoutes[pattern]] as const);
console.log(routes);
const path = location.pathname;
const match = routes.find(([pattern]) => pattern.exec(path));
if (!match) {
throw new Error(`No route found for ${path}`);
}
const importer = match[1];
const module = await importer();
const Page = module.default;
hydrate(
<App>
<Page />
</App>,
document.getElementById("app")!,
);
}
And now our src/entry-client.tsx
will become simply:
src/entry-client.tsx
import { startClient } from "../smf/client";
import { App } from "./App";
import { pageRoutes } from "./page-routes";
startClient({ App, pageRoutes }).catch((error) => {
console.error(error);
});
We'll add another page route and a navigation menu to test our client-side routing:
src/routes/about.page.tsx
export default function AboutPage() {
return <h1>About</h1>;
}
src/App.tsx
import type { AppProps } from "../smf/shared";
export function App(props: AppProps) {
return (
<div>
<nav>
<a href="/">Home</a> | <a href="/about">About</a>
</nav>
<main>{props.children}</main>
</div>
);
}
If everything goes well, you should be able to navigate between the two pages now! We have now something approaching a real metaframework. It could even be called useful if we had a way to pass data to our pages.
✅ Checkpoint: You can find the progress so far in the
chapter-06
tag.
Footnotes
-
The reason I picked Preact is that there doesn't seem to be a good Vite-based Preact metaframework as of yet. ↩