Today we’ll build a “metaframework”, starting with a plain Vite SPA and evolving it piece by piece into a server components app.
Solid 2.0 is well underway, and in the process it has been rethinking the role of its own metaframework SolidStart. It has been broken down into lego pieces which you can compose into your own opinionated framework.
TanStack Start also exists, but it’s missing a few things I want such as single flight mutations, query/action primitives, and server components. By building our own stack it lets us fill those gaps.
Important Decisions
When starting a new frontend UI project you have four main decisions to make up front. Let’s think through them in the general order you normally would.
1. Which UI framework?
Obviously for this one I am going to choose Solid, and specifically the in-progress Solid 2.0 beta.
2. Which Bundler?
Vite seems to be the obvious choice here.
3. Which Router?
The first two options are not too controversial, but here we have a bit more of a choice. We have two routers we can use: Solid Router or TanStack Solid Router.
Each has its pros and cons, but for this article I am going to go with TanStack Solid Router to prove the new Solid primitives work well with a third-party router.
4. Where are you going to deploy?
For this example app I am going to deploy to Cloudflare and use the Cloudflare Vite Plugin.
Building the Framework
Now that you have made these decisions the rest kind of falls out of them, especially since Solid has so much built in now. We’ll wire in the router and deployment later; first, the Solid core.
I’ll assume you know why these pieces matter and focus on how to get them.
Plain Vite Starter
First let’s start as bare bones as possible with a simple Solid Vite template. Choose a project name, and select Solid from the dropdown list.
pnpm create vite
Update to the Solid 2.0 beta
Update your package.json, tsconfig.app.json, and then run pnpm i
"dependencies": {
- "solid-js": "^1.9.14"
+ "solid-js": "2.0.0-beta.32",
+ "@solidjs/web": "2.0.0-beta.32"
},
"devDependencies": {
"@types/node": "^24.13.3",
"typescript": "~6.0.2",
"vite": "^8.2.0",
- "vite-plugin-solid": "^2.11.13"
+ "vite-plugin-solid": "^3.0.0-next.24"
}
// tsconfig.app.json
{
"compilerOptions": {
- "jsxImportSource": "solid-js",
+ "jsxImportSource": "@solidjs/web",
}
}
Enable turnkey SSR
Delete unneeded files like index.html, src/index.tsx, and src/index.css. For styling purposes just move the src/index.css contents to the top of src/App.css
In start mode the plugin generates the document server-side, so the static entry files are no longer needed.
Now you’re ready for the magic. Update your vite.config.ts by passing a start option to the solid plugin.
// vite.config.ts
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
export default defineConfig({
plugins: [
solid({
+ ssr: true,
+ start: {}
})
],
});
We now have SSR working - it was that easy! This little option does a lot of heavy lifting like eliminating flash of unstyled content, which is such a common pain point. You can prove that this is doing SSR by going to the Doc tab in the Network pane of Chrome dev tools.
Enable server functions
Ok, so now you want server functions. Easy enough:
// vite.config.ts
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
export default defineConfig({
plugins: [
solid({
ssr: true,
start: {},
+ serverFunctions: true
})
],
});
Enable server components
What about server components?
// vite.config.ts
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
export default defineConfig({
plugins: [
solid({
ssr: true,
start: {},
- serverFunctions: true
+ serverFunctions: {
+ components: true
+ }
})
],
});
That was easy, but I hear you being skeptical. Let’s quickly prove it works.
Make your App.tsx look like this.
import { createSignal, Show } from "solid-js";
import "./App.css";
import { dynamic, type JSX } from "@solidjs/web";
import { GET } from "@solidjs/web/server-functions";
const getApp = GET(() => {
"use server";
console.log("on the server");
return (props: { children: JSX.Element }) => (
<>
<section id="center">
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
{props.children}
</section>
</>
);
});
function App() {
const [visible, setVisible] = createSignal(false);
const [count, setCount] = createSignal(0);
const ServerApp = dynamic(() => getApp());
// Delay rendering so you can watch the server function request happen in the network tab.
setTimeout(() => {
setVisible(true);
}, 1000);
return (
<Show when={visible()}>
<ServerApp>
<button
type="button"
class="counter"
onClick={() => setCount(count => count + 1)}
>
Count is {count()}
</button>
</ServerApp>
</Show>
);
}
export default App;
I won’t get into exactly how this works as it’s still very experimental, but if you load the page you should see “on the server” printed in your terminal and not the browser.
Also if you look under Fetch/XHR in the Chrome dev tools you should see a request to /_server?id=8830d325-0-getApp and the response is the server component HTML instead of the JSON data you’re used to seeing.
;0x00000024;{"type":"start","id":"","version":1};0x000000f5;{"type":"html","id":"","version":1,"html":"<section id=\"center\"><div><h1>Get started</h1><p>Edit <code>src/App.tsx</code> and save to test <code>HMR</code></p></div><!--$--><!--slot:children:start--><!--slot:children:end--><!--/--></section>"};0x00000027;{"type":"complete","id":"","version":1}
Manage the head tags
With the server features working, let’s move on to managing the head tags. For that we need to install @solidjs/meta:
"dependencies": {
"solid-js": "2.0.0-beta.31",
"@solidjs/web": "2.0.0-beta.31",
+ "@solidjs/meta": "1.0.0-next.1"
},
Now you can just add a <Title> tag in the JSX wherever you want it.
+import { Title } from "@solidjs/meta"
function App() {
const [visible, setVisible] = createSignal(false);
const [count, setCount] = createSignal(0);
const ServerApp = dynamic(() => getApp());
setTimeout(() => {
setVisible(true);
}, 1000);
return (
<Show when={visible()}>
+ <Title>Hello World</Title>
<ServerApp>
<button
type="button"
class="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count()}
</button>
</ServerApp>
</Show>
);
}
export default App;
Add middleware
So now you’re thinking, what about middleware? Simply add this to your vite.config.ts
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
export default defineConfig({
plugins: [
solid({
ssr: true,
start: {
+ middleware: "./src/middleware.ts"
},
serverFunctions: {
components: true,
},
}),
],
});
Then create src/middleware.ts
async function loggingMiddleware(
_request: Request,
next: (request?: Request) => Promise<Response>
): Promise<Response> {
console.log("Logging from middleware");
return next();
}
export default [loggingMiddleware];
Add TanStack Router
Notice we’ve done this all without even having a router. I feel now it is time to add one. This is probably the most complicated part. Up until now we have used the default entries for server and client. With TanStack Router we will need to customize those. The vite-plugin-solid plugin will auto-detect these based on the filename.
But first let’s install the packages we will need:
"dependencies": {
"solid-js": "2.0.0-beta.31",
"@solidjs/web": "2.0.0-beta.31",
"@solidjs/meta": "1.0.0-next.0",
+ "@tanstack/router-core": "1.171.14",
+ "@tanstack/solid-router": "2.0.0-beta.29"
},
"devDependencies": {
"@types/node": "^24.13.3",
"typescript": "~6.0.2",
"vite": "^8.2.0",
"vite-plugin-solid": "^3.0.0-next.22",
+ "@tanstack/router-plugin": "1.168.19"
}
Then the files:
// src/router.tsx
import { createRouter } from "@tanstack/solid-router";
import { routeTree } from "./routeTree.gen.ts";
export function createAppRouter() {
return createRouter({
routeTree,
defaultPreload: "intent",
scrollRestoration: true,
});
}
declare module "@tanstack/solid-router" {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}
// src/entry-client.tsx
/* @refresh reload */
import { RouterProvider } from "@tanstack/solid-router";
import { hydrate as hydrateRouter } from "@tanstack/router-core/ssr/client";
import { hydrate } from "@solidjs/web";
import { createAppRouter } from "./router.tsx";
import { installServerComponents } from "@solidjs/web/frames";
const router = createAppRouter();
await hydrateRouter(router);
installServerComponents();
hydrate(() => <RouterProvider router={router} />, document);
// src/entry-server.tsx
/* @refresh reload */
import {
RouterServer,
createRequestHandler,
renderRouterToStream,
} from "@tanstack/solid-router/ssr/server";
import manifest from "virtual:solid-manifest";
import { getRequestEvent } from "@solidjs/web";
import { createAppRouter } from "./router.tsx";
export function render(request: Request, context: { clientEntry?: string }) {
const event = getRequestEvent();
if (event && context.clientEntry)
event.locals.clientEntry = context.clientEntry;
const handler = createRequestHandler({
request,
createRouter: createAppRouter,
});
return handler(({ request, responseHeaders, router }) => {
responseHeaders.set("content-type", "text/html; charset=utf-8");
return renderRouterToStream({
request,
responseHeaders,
router,
manifest,
children: () => <RouterServer router={router} />,
});
});
}
Then make your vite.config.ts like this:
import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
+ environments: {
+ ssr: {
+ optimizeDeps: {
+ exclude: ['@tanstack/solid-router'],
+ },
+ },
+ },
plugins: [
+ tanstackRouter({
+ target: 'solid',
+ autoCodeSplitting: true
+ }),
solid({
ssr: true,
start: {}
serverFunctions: { components: true },
}),
],
})
Then src/routes/__root.tsx:
// src/routes/__root.tsx
/* @refresh reload */
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
} from "@tanstack/solid-router";
import {
HydrationScript,
NoHydration,
getRequestEvent,
type JSX,
} from "@solidjs/web";
import appCss from "../App.css?url";
export const Route = createRootRoute({
component: Layout,
shellComponent: RootDocument,
head: () => ({
links: [{ rel: "stylesheet", href: appCss }],
}),
});
function RootDocument(props: { children: JSX.Element }) {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<HydrationScript />
<HeadContent />
</head>
<body>
<div id="root">{props.children}</div>
<Scripts />
<NoHydration>
<script
type="module"
src={
getRequestEvent()?.locals.clientEntry ?? "/src/entry-client.tsx"
}
async
></script>
</NoHydration>
</body>
</html>
);
}
function Layout() {
return <Outlet />;
}
Move the contents of App.tsx into a new src/routes/index.tsx, then delete App.tsx.
import { createFileRoute } from "@tanstack/solid-router";
import { createSignal, Show } from "solid-js";
import { dynamic, type JSX } from "@solidjs/web";
import { GET } from "@solidjs/web/server-functions";
import { Title } from "@solidjs/meta";
export const Route = createFileRoute("/")({
component: App,
});
const getApp = GET(() => {
"use server";
console.log("on the server");
return (props: { children: JSX.Element }) => (
<>
<section id="center">
<div>
<h1>Get started</h1>
<p>
Edit <code>src/routes/index.tsx</code> and save to test{" "}
<code>HMR</code>
</p>
</div>
{props.children}
</section>
</>
);
});
function App() {
const [visible, setVisible] = createSignal(false);
const [count, setCount] = createSignal(0);
const ServerApp = dynamic(() => getApp());
setTimeout(() => {
setVisible(true);
}, 1000);
return (
<Show when={visible()}>
<Title>Hello World</Title>
<ServerApp>
<button
type="button"
class="counter"
onClick={() => setCount(count => count + 1)}
>
Count is {count()}
</button>
</ServerApp>
</Show>
);
}
To make TypeScript happy with the virtual:solid-manifest add this to src/vite-env.d.ts
/// <reference types="vite/client" />
/// <reference types="vite-plugin-solid/virtual-solid-manifest" />
That’s more code than any step so far — and it’s precisely the code a metaframework writes for you. Now it’s ours to customize. If you run your dev server you will see the index page again, but this time we can add more routes. Try adding src/routes/about.tsx
Add query/action Primitives
One thing I usually miss when not using the Solid Router is its query/action primitives. We can rebuild them though. Copy the following files into your src folder. I won’t explain them as they mostly just come copy and pasted from Solid Router.
They basically allow us to do the following and have the getApp deduped so it doesn’t get called twice. It just warms the cache with the promise on hover so its available right away when we click.
export const Route = createFileRoute("/")({
component: App,
loader: () => {
void getApp();
},
});
function App() {
// ...
const ServerApp = dynamic(() => getApp());
// ...
}
Now just wrap our getApp in a query.
+import { query } from "../query";
+const getApp = query(() => {
"use server";
console.log("on the server");
return (props: { children: JSX.Element }) => (
<>
<section id="center">
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
{props.children}
</section>
</>
);
+}, "getApp", { dehydrate: false }););
Also if we wrap a function in an action it also allows us to do things like <form action={submitAction}>
Configure single flight mutations
Now typically when you do an action you do the POST followed by a GET. We can improve on this with single flight mutations.
First we need to configure Vite to use our server config.
import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid'
import { cloudflare } from '@cloudflare/vite-plugin'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
environments: {
ssr: {
optimizeDeps: {
exclude: ['@tanstack/solid-router'],
},
},
},
plugins: [
tanstackRouter({
target: 'solid',
autoCodeSplitting: true
}),
solid({
ssr: true,
start: { middleware: "./src/middleware.ts },
serverFunctions: {
+ configure: './src/server-config.ts',
components: true
},
}),
],
})
Now server-config.ts:
import { configureServerFunctionsServer } from "@solidjs/web/server-functions/server";
/**
* Single-flight mutations, server half.
*
* Re-runs the target location's data loading so this request's query cache
* holds the post-mutation values, then resolves that cache into the payload the
* client applies (see ./flight.ts).
*
* Only `warmQueriesFor` is host-specific; the hook above it is portable.
*/
import type { CollectFlightDataHook } from "@solidjs/web/server-functions/server";
import { provideRequestEvent } from "@solidjs/web/storage";
import { collectQueries } from "./query.ts";
import type { QueryFlightData } from "./flight";
export const collectQueryFlightData: CollectFlightDataHook = (event, outcome) =>
// The hook runs outside the request-event scope; re-establish it or the
// per-request query cache has no event to hang on (and in-process server
// function calls throw "Cannot call server function outside of a request").
provideRequestEvent(
event as Parameters<typeof provideRequestEvent>[0],
async () => {
if (outcome.thrown) return undefined;
// Where the client will be once this settles: a redirect's target, else
// the page it submitted from (same-origin fetches send a full Referer).
const href =
outcome.response?.headers.get("Location") ??
outcome.request.headers.get("referer");
if (!href) return undefined;
await warmQueriesFor(href, outcome);
// Single-flight is the point: the response waits for the data.
const queries: Record<string, unknown> = {};
await Promise.all(
Object.entries(collectQueries()).map(async ([key, promise]) => {
try {
queries[key] = await promise;
} catch {
// failed queries just aren't shipped; the client refetches on demand
}
})
);
return { href, queries } satisfies QueryFlightData;
}
);
// --- host-specific ---------------------------------------------------------
// TanStack Router: build a router at that location and load it — the route
// loaders call our queries (`void someQuery()`), which warms the cache.
import { createMemoryHistory } from "@tanstack/solid-router";
import { createAppRouter } from "./router.tsx";
async function warmQueriesFor(href: string, outcome: { request: Request }) {
const origin = new URL(outcome.request.url).origin;
// memory history wants a router path, not an absolute URL — hand it an
// absolute one and nothing matches, so no loader runs and nothing is warmed
const url = new URL(href, origin);
const router = createAppRouter();
router.update({
history: createMemoryHistory({
initialEntries: [url.pathname + url.search + url.hash],
}),
origin,
});
await router.load();
}
// this is the main config hook Solid gives us
configureServerFunctionsServer({
collectFlightData: collectQueryFlightData,
});
and src/flight.ts
// src/flight.ts
/**
* Single-flight mutations, client half + shared contract.
*
* The mutation's response carries the post-mutation values for the queries the
* page is showing, so the UI updates without a follow-up read. The server half
* (`createQueryFlightCollector`) lives on the `/server` subpath — it pulls in
* `provideRequestEvent` (node:async_hooks), which doesn't belong in client
* bundles.
*/
// Bare specifier on purpose: the compiled server-function references import
// '@solidjs/web/server-functions', and bundlers give each specifier its own
// module instance — the '/client' subpath would register the consumer in a copy
// the transport never reads. It also keeps this module importable from an SSR
// graph: the server build exports `subscribeFlightData` too.
import { subscribeFlightData } from "@solidjs/web/server-functions";
import { isServer } from "@solidjs/web";
import { query, revalidate } from "./query.ts";
export interface QueryFlightData {
href: string;
/** Post-mutation query results, resolved on the server: key -> value. */
queries: Record<string, unknown>;
}
/**
* Registers the flight-data consumer that applies single-flight query payloads.
* Call once on the client, before mutations can run (the client entry is the
* natural place). Subscribing IS the single-flight opt-in: the transport only
* sends the request-leg header, and the server only collects, while a consumer
* is registered — and only one can be active, so register it after any other
* integration that would claim the slot.
*/
export function installQueryFlightConsumer() {
if (isServer) return;
subscribeFlightData<QueryFlightData>(data => {
if (!data?.queries) return;
// The payload describes the location the mutation ran against; if the
// user navigated (or the mutation redirected) while it was in flight,
// seeding would write another page's data — refetch instead.
if (data.href !== window.location.href) return revalidate();
// `query.set` bumps each entry's version signal, so memos reading the
// query re-run with the fresh value — no client refetch.
for (const [key, value] of Object.entries(data.queries)) {
query.set(key, value as never);
}
});
}
Now add this toward the top of your src/routes/__root.tsx
import { installQueryFlightConsumer } from "../flight.ts";
installQueryFlightConsumer();
One last thing in src/router.tsx
+import { collectQueries, seedQueries } from "./query.ts";
+interface DehydratedQueries {
+ // Promise values are supported: TanStack serializes dehydrated data with
+ // seroval's crossSerializeStream, which streams promise resolutions to
+ // the client.
+ queries: Record<string, Promise<any>>;
+}
export function createAppRouter() {
return createRouter({
routeTree,
defaultPreload: "intent",
scrollRestoration: true,
+ dehydrate: (): DehydratedQueries => ({ queries: collectQueries() }),
+ hydrate: (data: DehydratedQueries) => {
+ seedQueries(data?.queries);
+ },
});
}
Again quite a bit of code but now now have single flight mutations! Maybe in the future this will be easier.
To test this out quickly just add an action to src/routes/index.tsx
let counter = 0;
const incrementAction = action(async () => {
"use server";
counter++;
return respond({ success: true });
});
<form action={incrementAction}>
<button type="submit">Increment</button>
</form>;
You should see only one POST request and it returns the flight data along with it.
Filesystem Api Routes
Now you want some server routes. Solid has a package for that!
pnpm install filesystem-routing
// vite.config.ts
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import { fileRoutes } from "filesystem-routing/vite";
export default defineConfig({
environments: {
ssr: {
optimizeDeps: {
exclude: ["@tanstack/solid-router"],
},
},
},
plugins: [
tanstackRouter({
target: "solid",
autoCodeSplitting: true,
+ routeFileIgnorePattern: "api/*",
}),
solid({
ssr: true,
start: { middleware: "./src/middleware.ts" },
serverFunctions: {
configure: "./src/server-config.ts",
components: true,
},
}),
+ fileRoutes({ httpMethods: true }),
],
});
// src/middleware.ts
import routes from "virtual:file-routes";
import { createAPIHandler } from "filesystem-routing/api";
export default [loggingMiddleware, createAPIHandler(routes)];
Add to src/vite-env.d.ts:
/// <reference types="filesystem-routing/types" />
Now create a file in ./src/routes/api/health.ts
export const GET = () => {
return new Response("OK");
};
When going to http://localhost:5173/api/health you should see a response of OK.
Add Cloudflare plugin
Lastly install the cloudflare plauin
pnpm i -D @cloudflare/vite-plugin
// vite.config.ts
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import { cloudflare } from "@cloudflare/vite-plugin";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
export default defineConfig({
environments: {
ssr: {
optimizeDeps: {
exclude: ["@tanstack/solid-router"],
},
},
},
plugins: [
tanstackRouter({
target: "solid",
autoCodeSplitting: true,
}),
+ cloudflare({
+ viteEnvironment: { name: "ssr" },
+ }),
solid({
ssr: true,
start: {
middleware: "./src/middleware.ts"
+ external: true
},
serverFunctions: {
configure: "./src/server-config.ts",
components: true,
},
}),
],
});
Then add to your wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "solid-2-cloudflare",
"main": "./src/worker.ts",
"compatibility_date": "2026-07-22",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./dist/client",
"binding": "ASSETS",
},
"observability": {
"enabled": true,
},
}
and add a src/worker.ts which mounts the solid SSR handler:
import { handleRequest } from 'virtual:solid-ssr-handler'
export default { fetch: (request: Request) => handleRequest(request) }
Then run pnpm build and pnpm preview which you can preview you production build or even do a wrangler deploy
Conclusion
Now as you can see there is a bit of setup to this, but I don’t think its unreasonable in the AI agent era. I have built the framework that I feel suits my needs. You can strip out certain parts if you don’t need them but you can scale all the way from a Vite SPA to full on server components without changing the architecture.
I’d like to hear what you think. What framework pieces are missing from your other favorite frameworks?