Migrating from Next.js to SolidStart: An Opinionated Guide
A practical guide to migrating from Next.js to SolidStart — focusing on the primitives, the mental model shift, and why it might be worth your time.

If you're looking for alternatives to Next.js - whether for ideological, practical, or curiosity-driven reasons - I think SolidStart is one of the best options out there. SolidStart is to SolidJS what Next.js is to React: the metaframework that adds routing, SSR, server functions, and deployment on top of the UI library. This guide covers both layers - the primitives and the metaframework - so you can evaluate whether it's the right move.
But first, the elephant in the room: Solid is not React. It looks like React, and that's both a blessing and a trap.
The one thing you need to internalize
In Solid, the component function is a constructor, not a render function.
In React, your component function re-runs on every state change. Every. Single. Time. Hooks, variables, derived values - all re-evaluated. In Solid, the component function runs once. It sets things up, wires the reactive graph together, and never runs again. All subsequent updates happen through signal subscriptions that surgically update the specific DOM nodes that care about the change.
Once you truly get this, every other difference clicks into place. Signals are getter functions because you need live references. Effects auto-track because they subscribe at call-time. Props can't be destructured because that reads values eagerly outside a tracking scope. <Show> and <For> exist because there's no virtual DOM to diff.
Let that sink in. Now let's get practical.
Solid vs. React: the primitives
On the surface, Solid and React feel similar - which is a good thing if you're coming from React land. Both use JSX to output HTML. But the similarities are surface-level. How they handle state, effects, iteration, conditional rendering, and lifecycle is fundamentally different.
Let's go through each, side by side.
1. State: useState vs createSignal
React:
function Counter() {
// This entire function re-runs on every setCount call
const [count, setCount] = useState(0);
console.log("rendered"); // fires every time
return <button onClick={() => setCount(count + 1)}>
{count}
</button>;
}
Solid:
function Counter() {
// This function runs ONCE
const [count, setCount] = createSignal(0);
console.log("setup"); // fires exactly once
return <button onClick={() => setCount(count() + 1)}>
{count()}
</button>;
}
The gotcha that'll bite you first: count is a function in Solid. You call count() to read the value. Writing {count} without the parentheses will render the function reference itself - not the number. This is, by far, the most common mistake React developers make when starting with Solid.
2. Effects: useEffect vs createEffect
React requires you to manually declare dependencies. Solid tracks them automatically.
React:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetchUser(userId).then(data => {
if (!cancelled) setUser(data);
});
return () => { cancelled = true; }; // cleanup via return
}, [userId]); // manual deps - forget this and you have a bug
return <div>{user?.name}</div>;
}
Solid:
function UserProfile(props) {
const [user, setUser] = createSignal(null);
createEffect(() => {
// Solid auto-tracks props.userId - no deps array
const controller = new AbortController();
fetchUser(props.userId, { signal: controller.signal }).then(setUser);
onCleanup(() => controller.abort()); // cleanup via onCleanup()
});
return <div>{user()?.name}</div>;
}
Key differences to watch for:
No deps array. Solid tracks what you read inside the effect automatically. If it reads
props.userId, it re-runs when that changes. Period.Cleanup uses
onCleanup(), not a return value. Import it fromsolid-js.No empty deps array pattern. In React,
useEffect(() => {...}, [])means "run once on mount." In Solid, useonMount()for that.Timing is different.
createEffectruns synchronously after the reactive graph updates - closer in spirit to React'suseLayoutEffectthanuseEffect, though the exact timing relative to browser paint depends on when the signal change was triggered. If you need explicit dependency control, Solid hason():
import { on } from "solid-js";
createEffect(on(count, (value, prev) => {
console.log("count changed from", prev, "to", value);
}));
3. Memos: useMemo vs createMemo
React's useMemo is a performance optimization, not a semantic guarantee - React may discard cached values. Solid's createMemo is a reactive primitive that creates a derived signal. It's guaranteed to be lazy and cached.
React:
function FilteredList({ items, filter }) {
const filtered = useMemo(
() => items.filter(i => i.category === filter),
[items, filter]
);
return <ul>{filtered.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
Solid:
function FilteredList(props) {
const filtered = createMemo(() =>
props.items.filter(i => i.category === props.filter)
);
return <ul>
<For each={filtered()}>{i => <li>{i.name}</li>}</For>
</ul>;
}
Same deal: createMemo returns a getter function - call filtered(), not filtered.
4. Conditional rendering: ternaries vs <Show>
In React, ternaries work because the virtual DOM diffs everything anyway - it papers over the cost for you. In Solid, there's no virtual DOM hiding that cost. Raw ternaries can cause DOM nodes to be torn down and recreated on every toggle. <Show> gives you efficient conditional rendering without the diffing overhead.
React:
function Greeting({ user }) {
return (
<div>
{user ? (
<h1>Welcome back, {user.name}!</h1>
) : (
<h1>Please sign in.</h1>
)}
</div>
);
}
Solid:
function Greeting(props) {
return (
<div>
<Show when={props.user} fallback={<h1>Please sign in.</h1>}>
{(user) => <h1>Welcome back, {user().name}!</h1>}
</Show>
</div>
);
}
The callback child form {(user) => ...} gives you a narrowed, guaranteed-truthy value. But note: user is a getter function in Solid, hence user().name.
For multiple conditions, <Switch> / <Match> is your friend:
<Switch fallback={<p>Not found</p>}>
<Match when={state() === "loading"}><Spinner /></Match>
<Match when={state() === "error"}><ErrorView /></Match>
<Match when={state() === "ready"}><Content /></Match>
</Switch>
5. List rendering: .map() vs <For>
In React, .map() recreates the virtual DOM array every render, then diffs with keys. In Solid, <For> tracks each item by reference - only affected DOM nodes get created, moved, or removed.
React:
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}
Solid:
function TodoList(props) {
return (
<ul>
<For each={props.todos}>
{(todo, index) => <li>{todo.text}</li>}
</For>
</ul>
);
}
A couple of things:
No
keyprop needed.<For>tracks by reference automatically.indexis a signal. Callindex()to get the number. The item itself (todo) is not a signal - it's the raw value.Using
.map()in Solid works - it won't crash your app - but it creates a non-keyed list that re-renders everything on every update. Essentially, it makes Solid behave like a slow React app (and if you do, I have questions).<For>is the correct tool for the job.
6. Refs: useRef vs… just a variable
This one's refreshingly simple.
React:
function AutoFocus() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
}, []);
return <input ref={inputRef} />;
}
Solid:
function AutoFocus() {
let inputRef;
onMount(() => {
inputRef.focus(); // no .current - it IS the element
});
return <input ref={inputRef} />;
}
No .current indirection. No hook. Just a variable. Since Solid components run once, a plain let persists for the component's lifetime. This also means you don't need useRef as a "mutable box" - any let variable already serves that purpose.
7. Props: the destructuring trap
This is the gotcha that bites hardest, because it works perfectly in React.
React - destructuring is totally safe:
function Greeting({ name, age }) {
return <p>{name} is {age} years old</p>;
}
Solid - destructuring BREAKS reactivity:
// BAD - values captured once, never update
function Greeting({ name, age }) {
return <p>{name} is {age} years old</p>;
}
// GOOD - reactive access through the props proxy
function Greeting(props) {
return <p>{props.name} is {props.age} years old</p>;
}
Why? Solid props are a Proxy. Accessing props.name inside JSX creates a reactive subscription. Destructuring eagerly reads the values during component setup (which runs once), severing the reactive connection forever.
When you need defaults or to split props for forwarding, use mergeProps and splitProps:
import { splitProps, mergeProps } from "solid-js";
function Button(props) {
const merged = mergeProps({ variant: "primary", size: "md" }, props);
const [local, rest] = splitProps(merged, ["variant", "size"]);
return (
<button class={`btn-\({local.variant} btn-\){local.size}`} {...rest}>
{props.children}
</button>
);
}
8. Building your own primitives
This is where Solid's "primitives, not frameworks" philosophy really pays off. In React, custom hooks are functions that call other hooks - but they re-run on every render, so you're always thinking about memoization and stale closures. In Solid, a custom primitive is just a function that wires up signals - it runs once, and the reactive graph handles the rest.
Here's a practical example: a createFetch primitive that handles loading, error, success, and data states.
import { createSignal, createEffect, onCleanup } from "solid-js";
function createFetch(urlFn) {
const [data, setData] = createSignal(null);
const [error, setError] = createSignal(null);
const [loading, setLoading] = createSignal(true);
createEffect(() => {
const url = urlFn(); // tracked - re-runs when URL changes
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`\({res.status} \){res.statusText}`);
return res.json();
})
.then((json) => {
setData(() => json);
setLoading(false);
})
.catch((err) => {
if (err.name !== "AbortError") {
setError(() => err);
setLoading(false);
}
});
onCleanup(() => controller.abort());
});
return { data, error, loading };
}
Use it in a component:
function UserProfile(props) {
const { data, error, loading } = createFetch(
() => `/api/users/${props.userId}`
);
return (
<Switch>
<Match when={loading()}>
<Spinner />
</Match>
<Match when={error()}>
<p>Error: {error().message}</p>
</Match>
<Match when={data()}>
{/* data() is the resolved JSON */}
<h1>{data().name}</h1>
<p>{data().email}</p>
</Match>
</Switch>
);
}
Notice what's not here: no dependency arrays, no memoization, no stale closure bugs. The URL is a getter function (urlFn()), so it's tracked automatically. When props.userId changes, the effect re-runs, the previous request is aborted, and the new one fires. All of this wired up in a reusable primitive that composes like a building block.
This is the pattern you'll reach for constantly in Solid - extract reactive logic into composable primitives that return signals. The Solid Primitives community library is built entirely on this idea.
Quick reference cheat sheet
| Concept | React | Solid | Gotcha |
|---|---|---|---|
| State | const [v, setV] = useState(0) |
const [v, setV] = createSignal(0) |
v() not v |
| Effects | useEffect(() => {}, [deps]) |
createEffect(() => {}) |
No deps array; onCleanup for cleanup |
| Memo | useMemo(() => x, [deps]) |
createMemo(() => x) |
Returns getter: memo() |
| Refs | useRef(null) + .current |
let ref; |
No .current |
| Conditionals | {cond ? <A/> : <B/>} |
<Show when={cond}>...</Show> |
Callback child for narrowing |
| Lists | arr.map(x => <X key={id}/>) |
<For each={arr}>{x => <X/>}</For> |
No key; index is a signal |
| Props | Destructure freely | Never destructure | Use splitProps / mergeProps |
| Component fn | Re-runs every render | Runs once | Derived values need () => wrapper |
Next.js vs SolidStart: the metaframework layer
With the primitives covered, let's zoom out. You're not just migrating from React to Solid - you're migrating from Next.js to SolidStart. That means routing, SSR, server-side code execution, API endpoints, data fetching, and all the other metaframework concerns. This is the part where I could just give you a table and call it a day. But some of these differences are subtle enough so that it warrants drawing a clearer line between the two.
Routing
Both frameworks use file-based routing, but the conventions differ.
Next.js (App Router):
app/
page.tsx → /
blog/
page.tsx → /blog
[slug]/
page.tsx → /blog/:slug
(marketing)/
about/
page.tsx → /about
layout.tsx → root layout
loading.tsx → loading UI
error.tsx → error boundary
Next.js uses special file conventions - page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx - each with specific roles. Route groups use parentheses (group). Layouts are implicit: a layout.tsx wraps all pages in its directory and below. Dynamic segments use [param], catch-all uses [...param].
SolidStart:
routes/
index.tsx → /
blog/
index.tsx → /blog
[slug].tsx → /blog/:slug
about.tsx → /about
blog.tsx → layout for /blog/*
SolidStart keeps it simpler. A file's name is the route - no page.tsx convention. Dynamic segments use [param], optional params use [[param]], catch-all uses [...param]. Layouts work by naming a file the same as a directory - blog.tsx alongside a blog/ folder makes blog.tsx the layout. Child routes render via props.children:
// routes/blog.tsx - layout for all /blog/* routes
export default function BlogLayout(props) {
return (
<div class="blog-wrapper">
<nav>Blog nav here</nav>
{props.children}
</div>
);
}
Route groups use parentheses (group)/, same idea as Next.js - directories wrapped in () that organize routes without affecting URL structure.
The key difference: Next.js has more special files with implicit behavior (loading states, error boundaries, not-found pages are all file-convention driven). SolidStart gives you the routing structure and lets you compose <Suspense>, <ErrorBoundary>, and <Show> yourself. Fewer conventions to memorize, more explicit control. 8/10 physicians agree that less magic in your code can have a positive impact on one's mental health.
SSR and rendering modes
Next.js gives you a buffet of rendering strategies:
Static (SSG) - pages pre-rendered at build time
Server-side (SSR) - rendered on each request
Incremental Static Regeneration (ISR) - static pages that revalidate after a set time
Partial Pre-rendering (PPR) - static shell with streaming dynamic holes (React 19.2)
React Server Components - components that run on the server and ship zero client JS
The rendering mode is determined by what you do in the component. Use "use cache"? Cached. Read cookies() or headers()? Dynamic. Five rendering strategies, and you don't pick one - the framework infers it from your code. What could possibly go wrong.
SolidStart is more straightforward:
SSR is on by default (
ssr: true). Every route is server-rendered.SSG/prerendering is opt-in via config:
// app.config.ts
export default defineConfig({
server: {
prerender: {
routes: ["/", "/about"],
// or: crawlLinks: true
},
},
});
SPA mode if you want it:
ssr: false.Streaming SSR is supported out of the box -
<Suspense>boundaries become streaming boundaries automatically.
No ISR, no PPR, no implicit mode switching. You pick a mode and that's what you get. If you need per-route control, you handle it with server functions and caching primitives, not framework magic.
Server-side code execution
This is where the philosophies diverge most.
Next.js has three main mechanisms for running code on the server:
- Server Components (the default in App Router) - your component function runs on the server. It can
awaitdata, touch the filesystem, query databases. The rendered output streams to the client as RSC Flight payload. The client never sees the component's JavaScript.
// This is a Server Component by default
async function ProductPage({ params }) {
const { id } = await params;
const product = await db.products.find(id);
return <ProductDisplay product={product} />;
}
- Server Actions - functions marked with
"use server"that handle mutations. Invoked from forms or client code, executed on the server.
"use server";
export async function addToCart(formData) {
const productId = formData.get("productId");
await db.cart.add(productId);
revalidatePath("/cart");
}
- Route Handlers - traditional API endpoints in
app/api/*/route.tsfiles.
The boundary between server and client is managed by directives: "use client" marks a component as client-side, "use server" marks a function as server-only. The mental model of what runs where is the number one source of confusion in the App Router, per community surveys.
SolidStart uses a single, consistent mechanism: server functions.
Any function annotated with "use server" runs on the server. That's it. No Server Components, no implicit server/client boundary - you explicitly opt into server execution per function.
// A server function for reading data
const getProduct = query(async (id) => {
"use server";
return await db.products.find(id);
}, "product");
// A server function for mutations
const addToCart = action(async (formData) => {
"use server";
const productId = formData.get("productId");
await db.cart.add(productId);
});
On the component side, you consume these with createAsync:
function ProductPage(props) {
const product = createAsync(() => getProduct(props.params.id));
return (
<Show when={product()}>
{(p) => <ProductDisplay product={p()} />}
</Show>
);
}
The difference in mental model is significant. If you've ever stared at a Next.js component wondering whether it runs on the server, the client, or both depending on the phase of the moon - yeah, that goes away. In SolidStart, all components run in the same context (SSR'd on the server, hydrated on the client) - you only think about which functions run on the server. One boundary to manage instead of two.
Data fetching
Next.js:
// Server Component - just await (server only)
async function Posts() {
const posts = await db.posts.findMany();
return <PostList posts={posts} />;
}
// With caching (v16+)
"use cache";
async function CachedPosts() {
const posts = await db.posts.findMany();
return <PostList posts={posts} />;
}
// Client-side - use SWR or TanStack Query
"use client";
function LivePosts() {
const { data } = useSWR("/api/posts", fetcher);
return <PostList posts={data} />;
}
Next.js has gone through three caching models in three major versions. v14 cached fetch() calls aggressively by default (which confused everyone). v15 removed default caching. v16 introduced "use cache" as an explicit opt-in. On top of that, there are four distinct cache layers: Request Memoization, Data Cache, Full Route Cache, and Router Cache. If you've ever felt like you needed a PhD in cache invalidation just to fetch a list of blog posts, you're not alone.
SolidStart:
// Define a cached query with a key
const getPosts = query(async () => {
"use server";
return await db.posts.findMany();
}, "posts");
// Preload in route config
export const route = {
preload: () => getPosts(),
};
// Consume in component
function Posts() {
const posts = createAsync(() => getPosts());
return (
<Suspense fallback={<Loading />}>
<For each={posts()}>
{(post) => <PostCard post={post} />}
</For>
</Suspense>
);
}
One caching primitive (query), one consumption primitive (createAsync), explicit cache keys, and <Suspense> for loading states. Mutations go through action(). Cache invalidation targets specific keys. That's the whole model.
Cache invalidation, cookies, and revalidation
Three things you'll need almost immediately after setting up data fetching:
Invalidating cache from an action:
When a mutation succeeds, you want to bust the relevant cache. In Next.js, you call revalidatePath() or revalidateTag(). In SolidStart, you use revalidate() with the cache key:
import { revalidate, action } from "@solidjs/router";
const addPost = action(async (formData) => {
"use server";
await db.posts.create({
title: formData.get("title"),
body: formData.get("body"),
});
revalidate("posts"); // bust the "posts" cache key
});
The cache key is the second argument you passed to query(). This is why explicit cache keys matter - you can surgically invalidate exactly what changed.
Cookies and cached queries:
In Next.js, reading cookies() or headers() inside a cached function implicitly makes it dynamic - the cache key includes the cookie values, but this behavior is invisible and easy to get wrong.
In SolidStart, if your server function reads cookies (via the request event), you need to be aware that query() caches by arguments only. The cookie value isn't automatically part of the cache key. If different users should see different data, pass a user identifier as an argument to the query, or use revalidate() on auth state changes:
const getUserData = query(async (userId) => {
"use server";
const session = getSession(); // read from cookie
if (!session) throw redirect("/login");
return await db.users.find(userId);
}, "userData");
Revalidation timing:
Next.js has ISR with revalidate: 60 for time-based cache expiry. SolidStart doesn't have a built-in time-based revalidation primitive. Your options:
Explicit invalidation via
revalidate()in actions (the recommended approach)revalidate()on the client to force a refresh of specific cache keysHTTP cache headers on your server functions if you want CDN-level caching:
const getPosts = query(async () => {
"use server";
const event = getRequestEvent();
event.response.headers.set("Cache-Control", "s-maxage=60, stale-while-revalidate");
return await db.posts.findMany();
}, "posts");
This is more explicit than ISR, but it's also more predictable - you know exactly what's cached and for how long.
API endpoints
Next.js uses Route Handlers:
// app/api/posts/route.ts
export async function GET(request: Request) {
const posts = await db.posts.findMany();
return Response.json(posts);
}
export async function POST(request: Request) {
const body = await request.json();
const post = await db.posts.create(body);
return Response.json(post, { status: 201 });
}
SolidStart uses API routes in the same routes/ directory:
// routes/api/posts.ts
import { type APIEvent } from "@solidjs/start/server";
export async function GET(event: APIEvent) {
const posts = await db.posts.findMany();
return Response.json(posts);
}
export async function POST(event: APIEvent) {
const body = await event.request.json();
const post = await db.posts.create(body);
return Response.json(post, { status: 201 });
}
Pretty similar on the surface, but there's an important difference hiding underneath. SolidStart uses the standard Request/Response Web APIs directly. Next.js also uses them in Route Handlers, but in practice you'll quickly run into NextRequest and NextResponse - extended wrappers that add things like nextUrl, cookies(), and geo. They're not standard, they don't exist outside Next.js, and they leak into your code in ways that make it non-portable. If you've ever tried to extract a Route Handler into a standalone function or test it without the Next.js runtime, you've felt this pain.
SolidStart gives you the event object with some extras (locals, request, response headers), but it's all built on Web APIs. The main difference is that SolidStart's API routes live alongside your page routes - no separate api/ directory convention needed (though you can organize them that way if you want).
Middleware
Next.js 16 introduced proxy.ts as the recommended path for heavy server-side logic - auth, redirects, rewrites - running on the Node.js runtime. The old middleware.ts isn't fully deprecated, but it's now specialized for Edge Runtime only. For self-hosted setups (think Hetzner, Coolify, Docker), proxy.ts is what you want.
SolidStart has createMiddleware:
import { createMiddleware } from "@solidjs/start/middleware";
export default createMiddleware({
onRequest: [authMiddleware, loggingMiddleware],
onBeforeResponse: [metricsMiddleware],
});
One important caveat with SolidStart's middleware: it does NOT run during client-side navigation. Only on the initial server request. So don't use it as your sole auth check - put auth logic in your server functions too. Next.js has the same nuance with proxy.ts, but it's less obvious.
What about after()? Next.js 15 introduced after() - a way to schedule work (logging, analytics, cache warming) that runs after the response has been sent to the client. SolidStart doesn't have a direct equivalent. Your best options are firing off a non-awaited promise in your server function (the response won't wait for it), or using the onBeforeResponse middleware hook to queue background work. If you're on a platform that supports it (like Cloudflare Workers with waitUntil()), you can tap into that directly via the platform's API. It's less ergonomic than after(), but it works - and you're not locked into a framework-specific API.
A note on auth in middleware: this is a broader anti-pattern that's worth calling out. Vercel spent years implicitly encouraging auth checks in Next.js middleware (their templates did it, their docs showed it), then pivoted to "we never said to do auth in middleware" when the Edge Runtime limitations became undeniable. The reality is that middleware auth is fine if it's asymmetric - checking a JWT signature or reading a session cookie without making additional network requests (no database lookups, no token introspection calls). The moment you need to hit an external service to validate, you're adding latency to every single request and creating a single point of failure. Put the real auth logic in your server functions where it belongs, and use middleware only for cheap, fast checks like redirecting unauthenticated users to a login page.
Deployment
Next.js works best on Vercel (unsurprisingly). Self-hosting has improved - the Build Adapters API is now stable, and Node.js/Docker deployments are better supported than before. But some optimizations are still Vercel-specific, and Turbopack remains Next.js-only - not the general-purpose bundler it was initially marketed as.
SolidStart deploys anywhere Nitro can target - and that's a long list: Node, Deno, Bun, Cloudflare (Workers, Pages), Netlify, Vercel, AWS Lambda, Deno Deploy, and more. Switch targets with a single config line:
export default defineConfig({
server: {
preset: "cloudflare_pages", // or "netlify", "node", "deno", etc.
},
});
No vendor lock-in. Vite is framework-agnostic with a massive ecosystem. This is the "primitives, not frameworks" philosophy applied to infrastructure.
Metaframework cheat sheet
| Concern | Next.js 16 | SolidStart |
|---|---|---|
| Routing | File-based, special files (page.tsx, layout.tsx, loading.tsx) |
File-based, file name = route, layouts via same-name convention |
| SSR | Default for Server Components, implicit mode switching | On by default, explicit config for SSG/SPA |
| Server code | Server Components + Server Actions + Route Handlers | Server functions ("use server") - one mechanism |
| Data fetching | async Server Components, "use cache", SWR/TanStack for client |
query() + createAsync() - one model |
| Caching | "use cache" + Cache Components (4 layers historically) |
query() with explicit cache keys |
| Mutations | Server Actions via "use server" in functions |
action() with "use server" |
| API routes | app/api/*/route.ts |
routes/api/*.ts |
| Middleware | proxy.ts (Node) + middleware.ts (Edge) |
createMiddleware (server-only) |
| Self-hosting | Stable Adapter API | Nitro presets (native) |
| Optimization | React Compiler (auto-memoization) | No compiler needed (signals) |
| Bundler | Turbopack (Next.js-only) | Vite (framework-agnostic) |
| Deployment | Best on Vercel, Adapter API for others | Anywhere via Nitro presets |
Primitives, not frameworks
Before we get into the "why bother" section, it's worth understanding the philosophy behind Solid. There's a mantra in the Solid community: "Primitives, not frameworks."
A note on origins: the phrase itself was actually coined by Werner Vogels, AWS CTO, in the context of cloud infrastructure during a Re:Invent keynote. Ryan Carniato ran with it and applied it to UI frameworks, where it's taken on a meaning of its own. It's a bit of an appropriation - Vogels was talking about composable cloud services, not reactive UI primitives - but the core idea translates well: give developers building blocks, not opinionated monoliths.
The idea is simple: instead of giving you a monolithic framework with opinions about every layer of the stack, Solid gives you small, composable, reactive building blocks that you combine however you need. To paraphrase Ryan's approach: here are a few powerful concepts - learn them, combine them, build on top of them. That's the whole thing.
This shows up everywhere in the ecosystem:
Solid Primitives - a community library of composable building blocks organized by domain (inputs, media, browser APIs, network, animation). Each one is tree-shakeable, SSR-safe, and individually useful.
SolidStart's architecture - v2 replaced its custom server layer (Vinxi) with Vite 6's Environment API, leveraging an existing ecosystem primitive rather than maintaining bespoke infrastructure.
The plugin model - features like image optimization are opt-in plugins, not baked into the framework. You compose what you need.
Contrast this with React's trajectory. React 19 shipped with Actions, useActionState, useOptimistic, use(), Server Components, Server Actions. React 19.2 added <Activity> (for hiding UI while preserving state and unmounting effects) and useEffectEvent. The React Compiler hit v1.0, auto-memoizing your code at build time. And that's before you add Next.js 16 on top with "use cache" directives, proxy.ts for server-side logic, Turbopack, and Build Adapters.
It's a lot. The React Compiler is genuinely impressive engineering - it makes React faster without changing the fundamental model. But in a sense, it's a patch on an architecture that other frameworks have moved past. Solid solves the performance problem at the architectural level via fine-grained reactivity, making a compiler unnecessary. Angular, Vue, and Svelte have all moved toward signals too. React is the outlier choosing to solve it with tooling rather than primitives. (Though it's worth noting that React itself has moved to an independent foundation under the Linux Foundation - a governance change that may influence its future direction.)
Neither approach is "wrong." But they lead to very different developer experiences.
Why bother? Performance and DX
So, that's a lot of "this works differently." Why go through the trouble?
Bundle size
Solid's core clocks in at ~7KB min+gzip. React + ReactDOM sits at ~45KB min+gzip. That's roughly a 6x difference at the framework level, and the gap compounds in real-world apps as you add routing, state management, and other dependencies. Less JavaScript shipped means faster load times. Simple math.
No virtual DOM overhead
React diffs a virtual tree on every state change, even with the Compiler auto-memoizing what it can. Solid compiles JSX to direct DOM operations. When a signal updates, only the specific DOM nodes that read that signal get touched. No diffing, no reconciliation, no wasted work. This is why Solid consistently ranks at or near the top of the JS Framework Benchmark, performing close to vanilla JavaScript.
The DX angle
Next.js has grown into a complex beast. Four layers of caching, three different caching models across three major versions (v14's aggressive implicit caching, v15's opt-out, v16's "use cache" opt-in), the server/client boundary dance, the middleware-to-proxy.ts migration… the State of JS 2025 survey showed Next.js with the largest satisfaction drop of any meta-framework (from 68% to 55%), landing as both the 13th most-loved and 5th most-hated project - uniquely polarizing. A commonly cited complaint? "Too complex." (paraphrased)
And then there was React2Shell (CVE-2025-55182) - a CVSS 10.0 critical RCE vulnerability in the React Server Components Flight protocol. A single malicious POST request to any server function endpoint could execute arbitrary code. Default create-next-app deployments were vulnerable out of the box. It was patched across all React 19.x lines (19.0.1, 19.1.2, and 19.2.1 - make sure you're on the right patch for your minor version), and Next.js shipped corresponding fixes. But the architectural debate about the Flight protocol - and the attack surface of running component deserialization on the server - remains a valid concern when evaluating RSC-heavy architectures.
SolidStart, by contrast, is refreshingly lean. File-based routing, "use server" directives for server functions, query() for cached data fetching, action() for mutations, and Vite under the hood. It leans on the web platform and doesn't try to reinvent every wheel. You write the reactive primitives we covered above, and the metaframework gets out of your way.
That said - SolidStart's ecosystem is smaller. You won't find the same breadth of third-party integrations, and the community, while active and growing (35K+ GitHub stars, ~1.5M weekly npm downloads), is not React-sized. It's a tradeoff worth being honest about - however, in my personal experience, that hasn't been a big deal. I've already shipped 5+ Solid-based projects of various complexity levels. I can't say the ecosystem size was a huge problem.
Can LLMs help with the migration?
Short answer: yes, but trust and verify.
LLMs like Claude, ChatGPT, and tools like Cursor are genuinely useful for the mechanical parts of migration. They'll convert useState to createSignal, swap className for class, strip out React.memo and useCallback (neither are needed in Solid), and handle the basic boilerplate. For a large codebase, that saves real time.
But here's the catch: LLMs are trained on way more React code than Solid code. Their muscle memory defaults to React patterns, and the places where they fail are exactly the places where Solid differs most:
Props destructuring - the #1 failure. Every LLM will write
({ name, onClick })by default. In Solid, this kills reactivity. You needprops.nameorsplitProps().Control flow - LLMs leave
.map(), ternaries, and&&in JSX instead of converting to<For>,<Show>, and<Switch>/<Match>. The JS patterns "work" but defeat Solid's fine-grained updates.The "runs once" model - LLMs generate code that assumes the component function re-runs on state changes. It doesn't in Solid.
Async in effects - LLMs will happily write
createEffect(async () => {...}), which breaks reactive tracking after the firstawait.
There's a documented case of ChatGPT confidently telling a user that a bug in their <Show> usage was a Solid framework bug. It wasn't. The LLM just didn't understand Solid's reactivity model.
Making it work
The good news is you can steer LLMs in the right direction:
SolidJS now ships an official llms.txt that you can feed to your AI tool as context. If you're using Cursor, add it via
@Docs.solidjs-context-llms is a community-maintained, LLM-optimized documentation suite organized by domain (reactivity, routing, SSR, primitives).
Cursor users can grab SolidJS-specific .cursorrules files that enforce the right patterns.
Claude Code users can add Solid rules to their
CLAUDE.mdto prevent the common mistakes.
But the real safety net is eslint-plugin-solid. Run it after every AI-assisted conversion. It catches reactivity violations that LLMs introduce: destructured props, conditional logic outside JSX, incorrect imports. Think of it as the linter that keeps the AI honest.
My recommended workflow: convert one component at a time, review the output for the known failure patterns, and run the linter before moving on. LLMs get you 70-80% of the way there. The last 20% is where understanding the mental model matters, and that's something you have to bring yourself.
A note on SolidStart v2
If you're evaluating SolidStart right now, you should know where things stand. SolidStart v2 is in late alpha and already being deployed in production by early adopters. The big change: it replaces Vinxi (v1's custom server layer) with Vite's Environment API, giving you a leaner, more debuggable stack that leverages Vite's existing ecosystem. Nitro 3 integration is a separate effort, still in progress.
The migration from v1 to v2 is minimal - mostly config changes. Your component code and server functions stay the same. Everything in this guide covers the fundamentals that apply to both versions.
Where to go from here
If you want to get your hands dirty:
SolidJS docs - genuinely well-written
SolidJS Tutorial - interactive, takes about an hour
SolidStart docs - for when you're ready to build an app
Solid Primitives - community library of composable building blocks
SolidStart v2 status - late alpha, heading toward beta, already deployed in production by early adopters
Takeaway
Migrating from React to Solid isn't a find-and-replace job. The JSX similarity is a double-edged sword - it makes the syntax feel familiar while hiding a fundamentally different execution model. But once the "runs once" mental model clicks, you'll find that Solid's approach is simpler and more predictable. And on a personal note, it makes my brain hurt less.
We're also witnessing a convergence on fine-grained reactivity as the right model - Angular adopted signals, Vue has ref() and reactive(), Svelte 5 moved to runes. Solid has been doing this from day one. The primitives are mature, the metaframework is production-ready, and the philosophy of composable building blocks over monolithic abstractions means you're not fighting the framework - you're using it as intended.
Whether you're moving away from Next.js for practical reasons, performance reasons, or just because you're curious about what fine-grained reactivity feels like in practice - give SolidStart a shot. The learning curve is real but short, and the payoff is worth it.
Happy hacking!
Originally published at darko.io by Darko Bozhinovski. Licensed under CC BY 2.0.





