18.6 SolidStart Basics
SolidStart is the official full-stack meta-framework developed by the Solid.js team. Just as Next.js sits on top of React, SolidStart adds file-based routing, server functions, SSR/SSG, and streaming on top of Solid.js. This chapter walks you through SolidStart's core concepts step by step, from the basics to production-ready usage.
1. What Is SolidStart?
Comparison with Other Meta-Frameworks
| Feature | Next.js (React) | SvelteKit | SolidStart |
|---|---|---|---|
| Base framework | React | Svelte | Solid.js |
| Routing | File-based | File-based | File-based |
| Server functions | Server Actions | Form Actions | "use server" RPC |
| Data loading | loader, fetch in RSC | load function | createAsync + cache |
| Streaming | React Suspense | Streaming | Solid Suspense |
| Deployment adapters | Vercel/custom | Vercel/CF/Node | Vercel/CF/Node/static |
| Bundler | Webpack/Turbopack | Vite | Vite (Vinxi-based) |
SolidStart uses Vinxi (a Vite-based app bundler) and supports a "mixed server/client in the same file" pattern where you can write server and client code side by side.
When Should You Choose SolidStart?
- Public-facing sites where SEO matters (requires SSR)
- Blogs and documentation sites (SSG is a great fit)
- Apps that need API routes and full-stack capabilities
- Cases requiring the highest-performance server rendering
If you only need a pure CSR single-page app, Solid.js + Vite alone is sufficient — SolidStart is not required.
2. Creating a Project
Starting a New Project
npm create solid@latest my-solid-app
cd my-solid-app
npm install
npm run dev
Options in the interactive setup:
- Template:
bare(empty project) orwith-tailwindcss,hackernews, etc. - Server Side Rendering: Yes (recommended)
- TypeScript: Yes (strongly recommended)
Project Structure
my-solid-app/
├── src/
│ ├── routes/ # File-based routing root
│ │ ├── index.tsx # / path
│ │ ├── about.tsx # /about path
│ │ ├── blog/
│ │ │ ├── index.tsx # /blog path
│ │ │ └── [slug].tsx # /blog/:slug dynamic route
│ │ └── (auth)/ # Layout group (not included in URL)
│ │ ├── login.tsx # /login
│ │ └── register.tsx # /register
│ ├── components/ # Reusable components
│ ├── lib/ # Utilities, DB connections, etc.
│ ├── app.tsx # App root component
│ └── entry-server.tsx # SSR entry point
├── public/ # Static files
├── app.config.ts # SolidStart configuration
└── package.json
Basic app.config.ts
// app.config.ts
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({
// Add Vite plugins
vite: {
plugins: [],
},
// Server configuration
server: {
preset: 'node', // 'vercel' | 'cloudflare' | 'netlify' | 'static'
},
});
3. File-Based Routing
Route File Naming Conventions
src/routes/
├── index.tsx → /
├── about.tsx → /about
├── contact.tsx → /contact
├── blog/
│ ├── index.tsx → /blog
│ └── [slug].tsx → /blog/:slug
├── shop/
│ ├── [...path].tsx → /shop/* (wildcard)
│ └── (category)/ → Layout group (not in URL)
│ ├── electronics.tsx → /shop/electronics
│ └── clothing.tsx → /shop/clothing
└── api/
└── users.ts → /api/users (API route)
Basic Route Component
// src/routes/index.tsx
export default function HomePage() {
return (
<main>
<h1>Home</h1>
<p>Welcome to the SolidStart app!</p>
</main>
);
}
Dynamic Routes
// src/routes/blog/[slug].tsx
import { useParams } from '@solidjs/router';
export default function BlogPost() {
const params = useParams();
return (
<article>
<h1>Post: {params.slug}</h1>
</article>
);
}
Nested Layouts
// src/routes/(app).tsx — nested layout file
import { Outlet } from '@solidjs/router';
export default function AppLayout() {
return (
<div class="app-layout">
<header>
<nav>
<a href="/">Home</a>
<a href="/dashboard">Dashboard</a>
<a href="/profile">Profile</a>
</nav>
</header>
<main>
<Outlet /> {/* Child routes render here */}
</main>
<footer>© 2025 My App</footer>
</div>
);
}
// src/routes/(app)/dashboard.tsx — child of the layout
export default function Dashboard() {
return <h1>Dashboard</h1>;
}
Link and Navigate
import { A, useNavigate } from '@solidjs/router';
function NavMenu() {
const navigate = useNavigate();
return (
<nav>
{/* A component — automatically adds active class */}
<A href="/" activeClass="active" end>Home</A>
<A href="/about" activeClass="active">About</A>
<A href="/blog" activeClass="active">Blog</A>
{/* Programmatic navigation */}
<button onClick={() => navigate('/dashboard')}>
Go to Dashboard
</button>
</nav>
);
}
4. Server Functions — "use server"
Core Concept
A function marked with the "use server" directive runs only on the server. When called from the client, it automatically sends the request as an RPC (Remote Procedure Call).
// src/routes/index.tsx
import { createSignal } from 'solid-js';
// This function will never be included in the client bundle
async function getServerData() {
'use server';
// Direct DB access, environment variables, and secret keys are all safe here
const db = await connectDB();
return db.query('SELECT * FROM products LIMIT 10');
}
export default function HomePage() {
const [data, setData] = createSignal(null);
onMount(async () => {
const result = await getServerData();
setData(result);
});
return <div>{/* ... */}</div>;
}
File-level "use server" — Mark an Entire File as Server-Only
// src/lib/db.ts
'use server';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client);
export async function getUsers() {
return db.select().from(users);
}
export async function createUser(data: NewUser) {
return db.insert(users).values(data).returning();
}
Form Actions
In SolidStart, form submissions are handled with action. Progressive Enhancement is supported — forms work even without JavaScript.
// src/routes/contact.tsx
import { action, useSubmission } from '@solidjs/router';
// Define action
const sendMessage = action(async (formData: FormData) => {
'use server';
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
if (!name || !email || !message) {
throw new Error('All fields are required');
}
await sendEmail({ name, email, message });
return { success: true };
}, 'sendMessage');
export default function ContactPage() {
const submission = useSubmission(sendMessage);
return (
<div>
<h1>Contact Us</h1>
{/* Connect via action="..." */}
<form action={sendMessage} method="post">
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<button type="submit" disabled={submission.pending}>
{submission.pending ? 'Sending...' : 'Send'}
</button>
</form>
<Show when={submission.error}>
<p class="error">{submission.error.message}</p>
</Show>
<Show when={submission.result?.success}>
<p class="success">Message sent successfully!</p>
</Show>
</div>
);
}
redirect and Cookies
import { action, redirect } from '@solidjs/router';
import { setCookie } from 'vinxi/http';
const loginAction = action(async (formData: FormData) => {
'use server';
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const user = await verifyCredentials(email, password);
if (!user) throw new Error('Invalid email or password');
const token = await generateToken(user.id);
// Set cookie
setCookie('auth_token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
// Redirect
throw redirect('/dashboard');
}, 'login');
5. Data Loading — createAsync, cache, query
cache and createAsync — The Basic Pattern
// src/lib/queries.ts
import { cache } from '@solidjs/router';
// Wrapping with cache runs it once on the server and caches the result
export const getProducts = cache(async () => {
'use server';
const products = await db.select().from(productsTable);
return products;
}, 'products'); // Second argument is the cache key
export const getProduct = cache(async (slug: string) => {
'use server';
const [product] = await db
.select()
.from(productsTable)
.where(eq(productsTable.slug, slug));
return product ?? null;
}, 'product');
// src/routes/shop/index.tsx
import { createAsync } from '@solidjs/router';
import { getProducts } from '~/lib/queries';
export default function ShopPage() {
// createAsync — runs ahead of time on the server, hydrates on the client
const products = createAsync(() => getProducts());
return (
<Suspense fallback={<p>Loading products...</p>}>
<For each={products()}>
{(product) => <ProductCard product={product} />}
</For>
</Suspense>
);
}
Dynamic Parameters and Data Loading
// src/routes/blog/[slug].tsx
import { useParams, createAsync } from '@solidjs/router';
import { cache } from '@solidjs/router';
const getPost = cache(async (slug: string) => {
'use server';
const post = await db
.select()
.from(postsTable)
.where(eq(postsTable.slug, slug))
.limit(1);
return post[0] ?? null;
}, 'post');
export default function BlogPostPage() {
const params = useParams();
const post = createAsync(() => getPost(params.slug));
return (
<Suspense fallback={<ArticleSkeleton />}>
<Show when={post()} fallback={<p>Post not found</p>}>
{(p) => (
<article>
<h1>{p().title}</h1>
<time>{p().publishedAt}</time>
<div innerHTML={p().content} />
</article>
)}
</Show>
</Suspense>
);
}
preload — Prefetching Data
// src/routes/blog/[slug].tsx
import { cache, createAsync, useParams } from '@solidjs/router';
const getPost = cache(async (slug: string) => {
'use server';
return fetchPost(slug);
}, 'post');
// preload: fetch data before entering the route
export const route = {
preload: ({ params }) => getPost(params.slug),
};
export default function BlogPostPage() {
const params = useParams();
const post = createAsync(() => getPost(params.slug));
// Thanks to preload, the Suspense boundary resolves immediately
return <article>{/* ... */}</article>;
}
6. SSR / SSG Mode Configuration
SSR (Server-Side Rendering) — Default
// app.config.ts
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({
server: {
preset: 'node', // SSR on a Node.js server
},
});
All routes render HTML on the server by default, then hydrate on the client.
SSG (Static Site Generation)
// app.config.ts
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({
server: {
preset: 'static', // Generates static HTML files at build time
prerender: {
crawlLinks: true, // Automatically pre-render by following links
routes: ['/'], // Start crawling from the root
ignore: ['/admin'], // Paths to exclude
},
},
});
For dynamic routes, list them explicitly in the prerender array.
prerender: {
routes: [
'/',
'/about',
'/blog/post-1',
'/blog/post-2',
// In practice, generate these dynamically by fetching slugs from a DB
],
},
Per-Route Configuration
// src/routes/dashboard.tsx — this route uses CSR only
export const route = {
// No server-side preload
};
// Export as SPA mode
export default function Dashboard() {
return <div>Dashboard (client-only)</div>;
}
7. Deployment Adapters
Vercel
npm install @solidjs/start
// app.config.ts
export default defineConfig({
server: { preset: 'vercel' },
});
// vercel.json (optional)
{
"buildCommand": "npm run build",
"outputDirectory": ".vercel/output"
}
Cloudflare Pages
// app.config.ts
export default defineConfig({
server: { preset: 'cloudflare-pages' },
});
# Deploy
npx wrangler pages deploy .output/public
On Cloudflare Workers, use import.meta.env instead of process.env.
Node.js (Self-Hosted Server)
// app.config.ts
export default defineConfig({
server: { preset: 'node' },
});
npm run build
node .output/server/index.mjs
Nginx reverse proxy configuration:
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Static (Fully Static)
// app.config.ts
export default defineConfig({
server: {
preset: 'static',
prerender: { crawlLinks: true, routes: ['/'] },
},
});
The resulting .output/public folder can be deployed to any static hosting service (GitHub Pages, S3, Netlify, etc.).
8. API Routes
// src/routes/api/users.ts
import type { APIEvent } from '@solidjs/start/server';
export async function GET(event: APIEvent) {
const users = await db.select().from(usersTable);
return new Response(JSON.stringify(users), {
headers: { 'Content-Type': 'application/json' },
});
}
export async function POST(event: APIEvent) {
const body = await event.request.json();
const [newUser] = await db
.insert(usersTable)
.values(body)
.returning();
return new Response(JSON.stringify(newUser), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
}
// src/routes/api/users/[id].ts
export async function GET(event: APIEvent) {
const id = event.params.id;
const user = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, parseInt(id)));
if (!user.length) {
return new Response('Not Found', { status: 404 });
}
return Response.json(user[0]);
}
export async function DELETE(event: APIEvent) {
const id = event.params.id;
await db.delete(usersTable).where(eq(usersTable.id, parseInt(id)));
return new Response(null, { status: 204 });
}
9. Practical Examples
Example 1: Blog — List + Detail + API
// src/lib/blog.ts
'use server';
import { cache } from '@solidjs/router';
export interface Post {
id: number;
slug: string;
title: string;
excerpt: string;
content: string;
publishedAt: string;
author: { name: string; avatar: string };
tags: string[];
}
// List query (cached for 5 minutes)
export const getPosts = cache(async (tag?: string) => {
const query = db.select().from(postsTable).orderBy(desc(postsTable.publishedAt));
if (tag) query.where(sql`${tag} = ANY(tags)`);
return query;
}, 'posts');
// Single post query
export const getPost = cache(async (slug: string) => {
const [post] = await db
.select()
.from(postsTable)
.where(eq(postsTable.slug, slug));
return post ?? null;
}, 'post');
// src/routes/blog/index.tsx
import { createAsync, A } from '@solidjs/router';
import { getPosts } from '~/lib/blog';
import { For, Suspense } from 'solid-js';
export const route = {
preload: () => getPosts(),
};
export default function BlogListPage() {
const posts = createAsync(() => getPosts());
return (
<main class="max-w-3xl mx-auto py-12 px-4">
<h1 class="text-4xl font-bold mb-8">Blog</h1>
<Suspense fallback={<PostsSkeleton />}>
<ul class="space-y-8">
<For each={posts()}>
{(post) => (
<li class="border-b pb-8">
<A href={`/blog/${post.slug}`}>
<h2 class="text-2xl font-semibold hover:underline">{post.title}</h2>
</A>
<time class="text-gray-500 text-sm">{post.publishedAt}</time>
<p class="mt-2 text-gray-700">{post.excerpt}</p>
<div class="mt-3 flex gap-2">
<For each={post.tags}>
{(tag) => (
<span class="bg-blue-100 text-blue-700 px-2 py-1 rounded text-xs">
{tag}
</span>
)}
</For>
</div>
</li>
)}
</For>
</ul>
</Suspense>
</main>
);
}
// src/routes/blog/[slug].tsx
import { createAsync, useParams, A } from '@solidjs/router';
import { getPost } from '~/lib/blog';
import { Show, Suspense } from 'solid-js';
export const route = {
preload: ({ params }) => getPost(params.slug),
};
export default function BlogDetailPage() {
const params = useParams();
const post = createAsync(() => getPost(params.slug));
return (
<Suspense fallback={<ArticleSkeleton />}>
<Show
when={post()}
fallback={
<div class="text-center py-24">
<h1 class="text-2xl">Post not found</h1>
<A href="/blog" class="text-blue-600 mt-4 block">Back to blog list</A>
</div>
}
>
{(p) => (
<article class="max-w-3xl mx-auto py-12 px-4">
<header class="mb-8">
<h1 class="text-4xl font-bold">{p().title}</h1>
<div class="flex items-center gap-4 mt-4">
<img
src={p().author.avatar}
alt={p().author.name}
class="w-10 h-10 rounded-full"
/>
<div>
<p class="font-medium">{p().author.name}</p>
<time class="text-sm text-gray-500">{p().publishedAt}</time>
</div>
</div>
</header>
<div class="prose" innerHTML={p().content} />
</article>
)}
</Show>
</Suspense>
);
}
Example 2: Authentication Flow — Login/Logout
// src/lib/auth.ts
'use server';
import { cache, action, redirect } from '@solidjs/router';
import { getCookie, setCookie, deleteCookie } from 'vinxi/http';
import { db } from '~/lib/db';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET!;
// Get current session user
export const getSessionUser = cache(async () => {
const token = getCookie('auth_token');
if (!token) return null;
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: number };
const [user] = await db
.select({ id: users.id, email: users.email, name: users.name, role: users.role })
.from(users)
.where(eq(users.id, payload.userId));
return user ?? null;
} catch {
return null;
}
}, 'session-user');
// Login action
export const loginAction = action(async (formData: FormData) => {
'use server';
const email = String(formData.get('email'));
const password = String(formData.get('password'));
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email));
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return { error: 'Invalid email or password' };
}
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
setCookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
});
throw redirect('/dashboard');
}, 'login');
// Logout action
export const logoutAction = action(async () => {
'use server';
deleteCookie('auth_token');
throw redirect('/login');
}, 'logout');
// src/routes/login.tsx
import { createAsync, useSubmission } from '@solidjs/router';
import { loginAction, getSessionUser } from '~/lib/auth';
import { Show } from 'solid-js';
import { redirect } from '@solidjs/router';
export const route = {
preload: async () => {
const user = await getSessionUser();
if (user) throw redirect('/dashboard');
},
};
export default function LoginPage() {
const submission = useSubmission(loginAction);
return (
<div class="min-h-screen flex items-center justify-center">
<div class="w-full max-w-md bg-white rounded-xl shadow p-8">
<h1 class="text-2xl font-bold mb-6">Login</h1>
<form action={loginAction} method="post" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Email</label>
<input
name="email"
type="email"
required
class="w-full border rounded-lg px-3 py-2"
placeholder="you@example.com"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Password</label>
<input
name="password"
type="password"
required
class="w-full border rounded-lg px-3 py-2"
/>
</div>
<Show when={submission.result?.error}>
<p class="text-red-500 text-sm">{submission.result?.error}</p>
</Show>
<button
type="submit"
disabled={submission.pending}
class="w-full bg-blue-600 text-white rounded-lg py-2 font-medium disabled:opacity-50"
>
{submission.pending ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
</div>
);
}
// src/routes/(app)/dashboard.tsx — protected route
import { createAsync } from '@solidjs/router';
import { getSessionUser, logoutAction } from '~/lib/auth';
import { Show } from 'solid-js';
import { redirect } from '@solidjs/router';
export const route = {
preload: async () => {
const user = await getSessionUser();
if (!user) throw redirect('/login');
},
};
export default function DashboardPage() {
const user = createAsync(() => getSessionUser());
return (
<div class="p-8">
<Show when={user()}>
{(u) => (
<div>
<h1 class="text-3xl font-bold">Dashboard</h1>
<p class="mt-2 text-gray-600">Welcome back, {u().name}!</p>
<form action={logoutAction} method="post" class="mt-6">
<button type="submit" class="bg-red-500 text-white px-4 py-2 rounded">
Logout
</button>
</form>
</div>
)}
</Show>
</div>
);
}
10. Pro Tips
Tip 1: Error Boundaries with ErrorBoundary
// src/app.tsx
import { ErrorBoundary } from 'solid-js';
export default function App() {
return (
<ErrorBoundary
fallback={(err, reset) => (
<div class="error-page">
<h1>An error occurred</h1>
<pre>{err.message}</pre>
<button onClick={reset}>Try Again</button>
</div>
)}
>
<Router />
</ErrorBoundary>
);
}
Tip 2: Authentication via Middleware
// src/middleware.ts
import { createMiddleware } from '@solidjs/start/middleware';
export default createMiddleware({
onRequest: [
async (event) => {
const url = new URL(event.request.url);
const token = getCookie(event, 'auth_token');
const protectedPaths = ['/dashboard', '/profile', '/settings'];
if (protectedPaths.some(p => url.pathname.startsWith(p))) {
if (!token || !(await verifyToken(token))) {
return new Response(null, {
status: 302,
headers: { Location: '/login' },
});
}
}
},
],
});
// app.config.ts
export default defineConfig({
middleware: './src/middleware.ts',
});
Tip 3: Initial Data Pattern with createAsync
// Reuse data already received from the server on the client
const data = createAsync(() => getData(), {
initialValue: props.serverData, // Initialize with data from SSR
deferStream: true, // Defer streaming
});
Tip 4: Cache Invalidation with revalidate
import { revalidate } from '@solidjs/router';
import { getProducts } from '~/lib/queries';
const deleteProduct = action(async (id: number) => {
'use server';
await db.delete(productsTable).where(eq(productsTable.id, id));
// Invalidate cache → automatically re-fetches on the next render
revalidate(getProducts.key);
}, 'deleteProduct');
Tip 5: Leveraging Streaming SSR
// Wrap heavy components in Suspense to stream the rest of the page first
export default function ProductPage() {
const product = createAsync(() => getProduct(params.slug));
const reviews = createAsync(() => getReviews(params.slug));
return (
<div>
{/* Fast data — streamed first */}
<Suspense fallback={<ProductSkeleton />}>
<ProductDetail product={product()} />
</Suspense>
{/* Slow data — streamed later */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewList reviews={reviews()} />
</Suspense>
</div>
);
}
Summary
| Feature | SolidStart API | Description |
|---|---|---|
| Routing | src/routes/ file structure | File name = URL path |
| Dynamic routes | [param].tsx | Accessed via useParams() |
| Layouts | (group).tsx + <Outlet /> | Layout groups with no URL impact |
| Server functions | "use server" | Server-only execution, automatic RPC |
| Form handling | action() + useSubmission() | Progressive Enhancement |
| Data loading | cache() + createAsync() | SSR + caching |
| Preloading | route.preload | Fetch data before entering the route |
| Cache invalidation | revalidate() | Automatic refresh after mutations |
| Deployment | preset configuration | Vercel/CF/Node/Static |
Up Next...
18.7 Pro Tips covers advanced patterns for using Solid.js proficiently in production. You'll learn about the dangers of destructuring, performance optimization with batch/untrack, TypeScript integration, testing, and how to migrate from React to Solid.js.