Solid-start guide for newbies

Vladislav Lipatov
11 min readMar 4, 2024

Prerequisites

This article was written for version solid-start 0.6. Perhaps the API will change in the future. This article is a small summary of the main points from the documentation and largely refers to it.

I expect the reader to be familiar with solid-js and @solidjs/router API, because in the examples below I will be using it.

We will create a small application in which we handle routing, loading data for the page, data mutation and synchronization with the UI.

In the examples below I’ll use TS, although you can also use JS.

Introduction

Solid-start is a meta-framework… Wait a minute! Strictly speaking, this is just a starter template for creating websites/applications using solid-js. It supports SSR (with optional streaming), SPA, SSG modes, so the desired result is up to you.

Under the hood, solid-start uses Nitro (which is used by many other frameworks), so it has fairly good server infrastructure support and flexible configuration.

Installation

To start working with solid-start, you need to run one of the following commands:


#npm
npm init solid@latest

#pnpm
pnpm create solid

#bun
bunx create-solid

Next, you will need to set up your project, install the dependencies, and you are ready to launch your first (is it the first?) application:

npm run dev

Project structure

Let’s look at the important files of our project.

In the project root you will see app.config.ts. This file contains the project configuration. You can see the available configuration options here.

By default, you will see several files in the src folder:
- entry-server.tsx — contains the code that will be executed on the server.
- entry-client.tsx — contains the code that will be executed in the browser.
- app.tsx — project entry point. This code will run both on the server and in the browser.

More about this here.

Solid-start operating modes

By default, solid-start works in SSR mode: that is, for the first page request, ready-made HTML is generated on the server, which, together with hydration scripts, is sent to the client, and further navigation is carried out using JS without completely reloading the page (unlike MPA solutions like Astro). However, you can prerender static pages using additional options that are set in the application configuration (more details here).

If you want to implement SPA, then you need to specify this in the config file:

// app.config.ts
import { defineConfig } from "@solidjs/start/config";

export default defineConfig({
// Now we have an SPA!
ssr: false,
});

Next, I will work with the standard SSR mode. Now let’s write a small application.

Routing

Probably the first thing we need is navigation between pages. By default, solid-start does not have a built-in router, so you can use any one or write your own. For clarity, I will use the official solid router.

File system routing

To create several pages for our site, just create the “routes” folder (you can set the name of this folder yourself through the project configuration file), and in this folder create several files that will export (default export) some solid components. These components will be our pages.

// Folder`routes` contains 2 files
routes:
- index.tsx
- about.tsx

index.tsx:

export default function Index() {
return <section>Index page</section>;
}

about.tsx:

export default function About() {
return <section>About page</section>;
}

When you run the project (hereinafter npm run dev), you will only see the contents of the app.tsx file, and navigation will not be available, that is, you will not see the pages that were just created in the example above. Why? The fact is that we still need to connect our router to the application.

Installing the router:

pnpm i @solidjs/router

And change the contents of the app.tsx file to the following:

import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start";
import { Suspense } from "solid-js";

export default function App() {
return (
<Router root={(props) => <Suspense>{props.children}</Suspense>}>
<FileRoutes />
</Router>
);
}

Important: Don’t forget to wrap props.children in Suspense, otherwise you will get a Hydration mismatch error.

Now, when you run the project, you will see the contents of the index page at http://localhost:3000/, and the contents of the about page at http://localhost:3000/about respectively. Primitive file-system routing is ready! You can read more about how to organize file system routing here.

Working with dynamic parameters

Let’s look at a simple example of how to work with dynamic parameters in a URL. For example, let’s create a User page, which will display the current userId, which we will take from the URL.

To do this, create a user folder in the routes folder, and then create a [id].tsx file in the user folder with the following content:

import { useParams } from "@solidjs/router";

export default function User() {
const params = useParams();

return (
<section>
<h1>User page</h1>
<p>User ID: {params.id}</p>
</section>
);
}

You may ask: why didn’t we create a user.tsx file? It’s all about how the file-based router is designed. If we created user.tsx, then this page would have no parameters, so when we go to http://localhost:3000/user/1 we would see a blank page, because we do not have the necessary file that would processed this path, that is, the path http://localhost:3000/user would work.

Now let’s change the contents of user/[id].tsx:

import { useParams } from "@solidjs/router";
import { createEffect } from "solid-js";

export default function User() {
const params = useParams();

createEffect(() => {
console.log("User ID:", params.id);
});

console.log("render");

return (
<section>
<h1>User page</h1>
<p>User ID: {params.id}</p>
{/* All parameters of `params` object are strings (or undefined) */}
<a href={`/user/${+params.id + 1}`}>Go to next user</a>
</section>
);
}

On the server you will only see one “render” (createEffect is not executed on the server). This is because the first time the page /user/1 is loaded, the server will return ready-made HTML and scripts that will make the page interactive, and subsequent navigations to user/[id] (when clicking on a) will be client-side, that is, work only on the client side. This happens because the router intercepts navigation information and carries out its logic, that is, the so-called “soft navigation” will be performed. This means that if we add a link to the about page to the current page:

<a href="/about">Go to about</a>

We will not get a reload of the entire page (as would happen in the case of MPA applications), but instead scripts will run that simply receive the necessary information from the server and change the DOM.

Please note that I am using a regular a tag, and not some component from the router library. This is because the router slightly changes the behavior of links by intercepting events. However, you can import the A component from the router library, which in some cases may be more useful (more about it here).

On the client, we will see in the console when we successively click on the “Go to next user” link:

render
[id].tsx:8 User ID: 159
[id].tsx:8 User ID: 160
[id].tsx:8 User ID: 161
[id].tsx:8 User ID: 162
[id].tsx:8 User ID: 163
...

What another conclusion can be drawn? The params object that is returned by the useParams function is a Proxy, so its properties can be used to implement reactivity.

Data loading

Now let’s imagine that we need to somehow load data for pages. You can download data both on the server and on the client.

Let’s add data loading for our page:

import { useParams } from "@solidjs/router";
import { Suspense, createEffect, createResource } from "solid-js";

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const fetchData = async (id: string) => {
// Simulate network delay
await wait(1000);

return { id };
};

export default function User() {
const params = useParams();
// Fetch data here
const [data] = createResource(() => params.id, fetchData);

createEffect(() => {
console.log("User ID:", params.id);
});

return (
<section>
<h1>User page</h1>
<p>User ID: {params.id}</p>

<Suspense fallback="Loading data...">
<p>User data: {data()?.id}</p>
</Suspense>

<a href={`/user/${+params.id + 1}`}>Go to next user</a>
<a href="/about">Go to about</a>
</section>
);
}

A fairly simple and understandable example: data is loaded in a component, and then… Wait a minute. Data is loaded in the component! This is not very good, because it leads to waterfalls if our page loads lazily: the browser must download the page code, parse it and only then load the data.

We can do it differently. Using router primitives, we can make the loading of data and page code parallel, that is, when the page code is parsed by the browser, the data for it can already be ready, which will have a positive effect on performance. To do this you need to take several steps:

Let’s wrap the fetchData function in cache:

const fetchData = cache(async (id: string) => {
await wait(1000);

return { id };
}, 'data');

Replace createResource with createAsync (or createAsyncStore):

const data = createAsync(() => fetchData(params.id));

Please note that the data will be downloaded both on the client and on the server. This happens because the first time the page is loaded, the server needs to send ready-made HTML, which may contain the data used.

Why then do we need to download data in the browser as well? This is necessary if the URL parameters change in subsequent navigation and new data is loaded depending on these parameters. Subsequent downloads will only occur in the browser.

Optionally we will also support data preloading:

export const route = {
load: ({ params }) => fetchData(params.id),
} satisfies RouteDefinition;

Thanks to this feature, a simple hover on a link will already load the data for the next page, making navigation between pages almost instantaneous (this behavior can also be disabled).

Final version:

import { RouteDefinition, cache, createAsync, useParams } from "@solidjs/router";
import { Suspense, createEffect, createResource } from "solid-js";

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const fetchData = cache(async (id: string) => {
await wait(1000);

return { id };
}, "data");

export const route = {
load: ({ params }) => fetchData(params.id),
} satisfies RouteDefinition;

export default function User() {
const params = useParams();
const data = createAsync(() => fetchData(params.id));

createEffect(() => {
console.log("User ID:", params.id);
});

return (
<section>
<h1>User page</h1>
<p>User ID: {params.id}</p>

<Suspense fallback="Loading data...">
<p>User data: {data()?.id}</p>
</Suspense>

<a href={`/user/${+params.id + 1}`}>Go to next user</a>
<a href="/about">Go to about</a>
</section>
);
}

Server-only data loading

What if we want to load data only on the server? Let’s say we read a local database and send this data to the client. Achieving this behavior is very simple: just add ”use server” in the function declaration for cache:

const fetchData = cache(async (id: string) => {
"use server";
// This code will always run on the server

await wait(1000);

return { id };
}, "data");

solid-start will create an RPC and transfer the data in native format.

API routes

Since we have control over the server, we can create our own API routes to work with data so that external clients can also make requests to the server.

Let’s create a GET route that will return some data. To do this, we need to create an api folder in the routes folder, and then create a data.ts file:

export const GET = () => {
return new Response(JSON.stringify({ message: "Data from api route" }));
};

Now we can make GET requests to /api/data:

fetch("http://localhost:3000/api/data").then(console.log);

We can declare other methods, more about that here.

Data mutations

Now let’s figure out how we can change the data that is on the server. To do this, add the name field to the user model, which we will change. name is stored on the server and is modified using some HTTP request to the server.

let SERVER_NAME = "John Doe";

const fetchData = cache(async (id: string) => {
await wait(1000);

return { id, name: SERVER_NAME };
}, "data");

Let’s imagine that we have a form in which we can set a new name for the user:

// We don't know the action yet
<form>
<input type="text" name="name" placeholder="Enter new name" />
<button type="submit">Change user's name</button>
</form>

Let’s add the output of our field under Suspense.

<Suspense>
<p>User data: {data()?.id}</p>
<p>User's name: {data()?.name}</p>
</Suspense>

Now on submit event of the form we should write something like this:

<form onSubmit={(e) => {
// prevent page reload
e.preventDefault();

const formData = new FormData(e.currentTarget);

fetch("some API endpoint", {
body: JSON.stringify({ name: formData.get("name") }),
}).then(() => {
// refetch server data to update the UI
});
}}>...</form>

We can save time thanks to the action wrapper, which will do all the work for us:

const changeNameAction = action(async (formData: FormData) => {
// emulate fetch call
SERVER_NAME = formData.get("name") as string;
}, "change-state-action");

Let’s pass the action to the form:

```tsx
<form
action={changeNameAction}
method="post"
>...</form>

An important point: this format only works when method=”post”.

What’s going on here? On the form’s submit event, a function will be called that will automatically call e.preventDefault(). Next, the function that was defined inside action will be called, and then all page data will be invalidated, and the UI will be updated automatically. Note that this will fetch all the data that the page needs to display the UI. If this behavior does not suit you, then you can specify which data you need to invalidate:

import { action, cache } from "@solidjs/router";

const changeNameAction = action(async (formData: FormData) => {
// emulate fetch call
SERVER_NAME = formData.get("name") as string;

// specify the keys of action you want to invalidate
return reload({ revalidate: [fetchData.key] });
}, "change-state-action");

You can learn more about invalidation here.

Final code:

import { RouteDefinition, action, cache, createAsync, reload, useParams } from "@solidjs/router";
import { Suspense, createEffect } from "solid-js";

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

let SERVER_NAME = "John Doe";

const fetchData = cache(async (id: string) => {
await wait(1000);

return { id, name: SERVER_NAME };
}, "data");

const changeNameAction = action(async (formData: FormData) => {
SERVER_NAME = formData.get("name") as string;

return reload({ revalidate: fetchData.key });
}, "change-state-action");

export const route = {
load: ({ params }) => fetchData(params.id),
} satisfies RouteDefinition;

export default function User() {
const params = useParams();
const data = createAsync(() => fetchData(params.id));

createEffect(() => {
console.log("User ID:", params.id);
});

return (
<section>
<h1>User page</h1>
<p>User ID: {params.id}</p>
<form action={changeNameAction} method="post">
<input type="text" name="name" placeholder="Enter new name" />
<button type="submit">Change user's state</button>
</form>
<Suspense>
<p>User data: {data()?.id}</p>
<p>User's state: {data()?.name}</p>
</Suspense>
<a href={`/user-loading/${+params.id + 1}`}>Go to next user</a>
<a href="/about">Go to about</a>
</section>
);
}

Manual call action

We can’t always send data through a form (probably). There are different scenarios where we want to call action from JS code, is it possible to do this? Solid router provides an additional feature that will help in such cases:

// inside a component
const changeName = useAction(changeNameAction);

// then inside JSX
<button
type="button"
onClick={() => {
changeName(new FormData(document.querySelector("form") as HTMLFormElement));
}}>
Change user's name
</button>

What is useAction for? It forwards the necessary router data to the action, which it takes from the context. The fact is that in this example, the changeName function is already bound to the router context, since it was created inside the component. This is why we can safely call our action on the click event.

Server actions

Like the data loading functions, actions can only be executed on the server. To do this, we need to specify “use server” in the action definition:

const changeNameAction = action(async (formData: FormData) => {
"user server";

SERVER_NAME = formData.get("name") as string;

return reload({ revalidate: fetchData.key });
}, "change-state-action");

Then an RPC will be created for this action.

Handling action state

Now we know how to change our data on the server, but it is often quite useful to show some information to the user that an action is being executed (for example, showing a spinner). How can we get this information?

To do this, Solid router provides another function: useSubmission (or useSubmissions for multiple calls):

import { useSubmission } from '@solidjs/router';

// inside a component
const changeNameSubmission = useSubmission(changeNameAction);
createEffect(() => {
if (changeNameSubmission.pending) console.log("Changing user name...");
});

useSubmission also provides a number of useful information that can be used for optimistic updates. You can find the useSubmission definition here.

Conclusion

So, what did we get? We’ve learned how to create application pages using solid-start, how to get data for pages, how to change that data using API calls, and how to use information about those changes.

Of course, I have not talked about all the points, so perhaps after a while I will write another article on this topic.

I hope you learned something useful!

--

--