@jk2908/solas
v0.5.4
Published
A Vite + React meta-framework exploring streaming, Server Components, and partial prerendering. Designed for simplicity and lightness
Readme
Solas
Solas is a minimal React meta-framework powered by Vite, created for experimenting with routing, streaming, and prerendering with React Server Components.
Solas is experimental and currently has no automated test suite, so expect rough edges.
Install
npm install @jk2908/solas react react-dom react-server-dom-webpack vite
npm install -D @vitejs/plugin-react typescript vite-tsconfig-pathsUse
Create a Vite config that registers Solas.
import { defineConfig } from 'vite'
import solas from '@jk2908/solas'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [solas(), react()],
})Structure
Put your routes in app/.
app/
+layout.tsx
+page.tsx
+middleware.ts
+loading.tsx
+401.tsx
+403.tsx
+404.tsx
+500.tsx
about/
+layout.tsx
+page.tsx
api/
+endpoint.ts
posts/
[id]/
+page.tsxUse these filename conventions:
+layout.tsx: shared layout for a route branch.+page.tsx: page component for a route.+endpoint.ts: request handler. Can be placed in any folder and responds to all HTTP methods for its route path.+middleware.ts: middleware that runs for the current route branch and is inherited by child routes. Parent and child middleware stack together.+loading.tsx: loading fallback inherited by child routes.+401.tsx: boundary for unauthorised responses in the current route branch and its children.+403.tsx: boundary for forbidden responses in the current route branch and its children.+404.tsx: boundary for not found responses in the current route branch and its children.+500.tsx: boundary for server errors in the current route branch and its children.
Nested folders create nested routes. Dynamic segments use [param], and catch-all segments use [...param].
Status boundaries follow the same override pattern as layouts: a child route uses the nearest matching boundary file above it, and a more specific boundary replaces a parent one.
If a route has both +page.tsx and +endpoint.ts, Solas selects the GET handler by Accept header:
Accept: text/htmlortext/x-component: render+page.tsx- other GET requests (for example
application/json): run+endpoint.tsGET
Non-GET methods (POST, PUT, PATCH, DELETE) always run +endpoint.ts.
Client Routing
Import client navigation helpers from @jk2908/solas/router.
import { Link, useRouter } from '@jk2908/solas/router'Solas generates route types for your app. Link and router.go(...) use those generated route types for autocomplete and type checking:
<Link href="/posts" />
<Link href="/p/:id" params={{ id: 'post-1' }} />
await router.go('/posts')
await router.go('/p/:id', { params: { id: 'post-1' } })That gives you:
- autocomplete for known route paths
- required params for dynamic routes like
/p/:id - rejected params for static routes that do not accept them
- typed query and navigation options on
router.go(...)
If you already have a concrete path, you can pass that directly instead of using route params:
;<Link href={`/p/${post.id}`} />
await router.go(`/p/${post.id}`)In that form the path is already resolved, so params are not used. Typed Link and router.go(...) only allow params when you pass a route pattern like /p/:id.
If the target already contains a query string and you also pass query, Solas merges them. Existing query entries are kept unless you override the same key in query, and explicit query values win:
<Link href={`/p/${post.id}?tab=summary`} query={{ draft: true, tab: 'full' }} />That resolves to /p/${post.id}?tab=full&draft=true.
Use Link for same-origin app navigation. Prefetching is opt-in:
<Link href="/posts">Posts</Link>
<Link href="/posts" prefetch="intent">Prefetch on focus or touch</Link>
<Link href="/posts" prefetch="hover">Prefetch on hover</Link>
<Link href="/p/:id" params={{ id: 'post-1' }}>Typed params</Link>prefetch="none" is the default. Solas does not automatically prefetch routes unless you opt in with Link or call router.prefetch(...) yourself.
Use useRouter() inside client components for programmatic navigation, prefetching, and refreshing the current route:
'use client'
import { useRouter } from '@jk2908/solas/router'
export function Controls() {
const router = useRouter()
return (
<>
<button type="button" onClick={() => router.go('/posts')}>
Go to posts
</button>
<button type="button" onMouseEnter={() => router.prefetch('/posts')}>
Prefetch posts
</button>
<button type="button" onClick={() => router.refresh()}>
Refresh current route
</button>
</>
)
}router.go(...) and router.refresh() both return promises, so you can await either of them when you need to sequence work after navigation completes:
const finalPath = await router.go('/posts')await router.refresh()router.refresh() always refreshes the current browser location at the moment you call it. If you call it after await router.go('/posts'), it refreshes /posts (or the final redirected path).
router.go(...) accepts route params and query values using the same typed route rules as Link:
await router.go('/p/:id', {
params: { id: 'post-2' },
query: { draft: true },
})Those same generated route types can also be reused outside navigation helpers when you want route params to stay typed in page exports:
import type { Route, Solas } from '@jk2908/solas'
export const metadata: Route.Metadata<Solas.Routes['/writing/:slug']> = ({ params }) => {
const post = allPosts?.find(p => p.__mdsrc.slug === params?.slug)
return {
title: post?.title ?? 'Post not found',
}
}
export const params: Route.StaticParams<Solas.Routes['/writing/:slug']> = () =>
allPosts?.map(p => ({ slug: p.__mdsrc.slug })) ?? []That keeps your route params aligned across links, imperative navigation, metadata, and static params.
router.refresh() clears the cached RSC response for the current path and fetches a fresh payload, so it is most useful for routes that render request-time data. router.isNavigating exposes pending client-side navigation state.
Config
All Solas options are passed to solas() inside defineConfig.
Runtime
Solas uses a runtime for filesystem access, mime lookup, and hashing.
This is not a deployment or packaging adapter. Platform adapters are not available yet.
Use the runtime config key to select the runtime used by Solas server code.
Supported values are 'auto', 'node', and 'bun'.
solas() defaults to runtime: 'auto'. In a Bun process, that selects Bun. Otherwise it falls back to Node.
If you already run Vite through Bun, runtime: 'auto' is usually enough and Solas will detect Bun automatically.
Node runtime
If you want to pin Node explicitly, set runtime: 'node':
import { defineConfig } from 'vite'
import solas from '@jk2908/solas'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [solas({ runtime: 'node' }), react()],
})Use the normal Vite commands with the Node runtime:
{
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
}
}Bun runtime
If you want Solas runtime code to execute in Bun, set runtime: 'bun':
import { defineConfig } from 'vite'
import solas from '@jk2908/solas'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [solas({ runtime: 'bun' }), react()],
})When you use the Bun runtime, run Vite through Bun so the server/runtime code executes in a Bun process:
{
"scripts": {
"dev": "bunx --bun vite dev",
"build": "bunx --bun vite build",
"preview": "bunx --bun vite preview"
}
}url
url is optional. If you set it, Solas treats it as the public origin for your app.
Solas resolves it in this order:
- the
urloption passed tosolas() VITE_APP_URL
Solas exposes the resolved value as import.meta.env.VITE_APP_URL. If url is set, prerender also uses it as the request origin for build-time renders.
In practice, you only need url if your app reads import.meta.env.VITE_APP_URL or your prerendered output needs a real public origin.
If you do want to set it explicitly, this is the shape:
export default defineConfig(({ mode }) => ({
plugins: [
solas({
url: mode === 'production' ? 'https://example.com' : 'http://localhost:8787',
}),
],
}))If you prefer an environment variable, set this instead:
VITE_APP_URL=https://example.comport
Use port to change the development server port.
Default: 8787
export default defineConfig({
plugins: [
solas({
port: 4000,
}),
],
})precompress
Use precompress to control whether Solas writes compressed browser-served build assets (like .js, .css, etc.).
Default: false
export default defineConfig({
plugins: [
solas({
precompress: false,
}),
],
})prerender
Use prerender to set the default prerender mode for the app. Valid values are full, ppr, and false.
Default: false
false: do not prerender the route.full: render the full route to HTML at build time.ppr: prerender a static shell and defer dynamic regions to request time.
export default defineConfig({
plugins: [
solas({
prerender: 'ppr',
}),
],
})This value is only the default. Route files can override it with export const prerender = ..., and the nearest explicit export wins.
// vite.config.ts
export default defineConfig({
plugins: [solas({ prerender: 'full' })],
})// app/about/+layout.tsx
export const prerender = 'ppr'// app/about/team/+page.tsx
export const prerender = falseIn that example, the app default is full, the about layout overrides it to ppr, and the page overrides it again to false.
For dynamic routes, prerendering uses the params you export from the page:
export const params = () => [{ id: 'post-1' }, { id: 'post-2' }]
export const prerender = 'full'In ppr mode, Solas prerenders the shell and lets you defer parts of the tree to request time.
Use dynamic() inside a Suspense boundary to mark a subtree as request-time only:
import { Suspense } from 'react'
import { dynamic } from '@jk2908/solas/server'
export const prerender = 'ppr'
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Ts />
</Suspense>
)
}
async function Ts() {
await dynamic()
return <div>{Date.now()}</div>
}During prerender, dynamic() suspends so the nearest Suspense fallback is written into the static shell. At request time, the deferred content resolves normally.
If you call dynamic() outside ppr mode, Solas does not defer that subtree. In full mode it logs a warning and the component still renders at build time.
headers(), cookies(), and url() also mark the current render path as dynamic, so they should be treated the same way when you are building a ppr shell.
metadata
Use metadata to set default document metadata.
export default defineConfig({
plugins: [
solas({
metadata: {
title: '%s - Solas',
meta: [
{
name: 'description',
content: 'My Solas app',
},
],
},
}),
],
})This is also only the default. Route metadata is merged in order, so config metadata can be extended or overridden by the shell, layouts, page, and status boundaries. The later, more specific route metadata wins for titles and duplicate tags.
// vite.config.ts
solas({
metadata: {
title: '%s - Solas',
},
})
// app/+layout.tsx
export const metadata = {
title: 'Docs',
}
// app/guides/+page.tsx
export const metadata = {
title: 'Routing',
}In that example, the final page title becomes Routing - Solas.
trailingSlash
Use trailingSlash to set the app-wide URL policy.
Default: never
never:/about/redirects to/aboutalways:/aboutredirects to/about/ignore: both forms resolve without a canonical redirect
This is a global setting in solas(). Solas does not read trailingSlash from route files.
Prerendered output follows the same policy. always writes route HTML as about/index.html, while never and ignore write it as about.html.
export default defineConfig({
plugins: [
solas({
trailingSlash: 'always',
}),
],
})trustedOrigins
Use trustedOrigins to allow specific origins to make cross-origin browser submissions to your app.
Default: []
By default, only same-origin browser requests are allowed. Add a trusted origin when a third-party service needs to submit through the user's browser, such as a payment gateway or identity provider.
This setting controls which cross-origin browser sources are allowed for unsafe requests. See Security > CSRF for enforcement details.
Each value must be a complete origin including protocol:
export default defineConfig({
plugins: [
solas({
trustedOrigins: ['https://payments.example.com', 'https://login.example.com'],
}),
],
})Only add origins you completely trust.
sitemap
Use sitemap to generate a sitemap.xml at build time.
Default: false
When enabled, Solas writes a sitemap containing all routes with deterministic URLs: static routes, prerendered routes, and dynamic routes resolved via params. The origin for each URL comes from config.url.
export default defineConfig(({ mode }) => ({
plugins: [
solas({
url: mode === 'production' ? 'https://example.com' : 'http://localhost:8787',
sitemap: true,
}),
],
}))Routes with dynamic segments ([id]) or catch-all segments ([...param]) are only included if they export params and prerender.
To add routes that Solas cannot discover automatically (for example, catch-all routes backed by a CMS), pass an object with a routes function. The function receives the auto-discovered routes and returns the final list:
export default defineConfig(({ mode }) => ({
plugins: [
solas({
url: mode === 'production' ? 'https://example.com' : 'http://localhost:8787',
sitemap: {
async routes(existing) {
const posts = await getPosts()
return [...existing, ...posts.map(p => `/blog/${p.slug}`)]
},
},
}),
],
}))The routes function can be async. The callback also lets you filter routes:
sitemap: {
routes: (r) => r.filter(route => !route.startsWith('/admin')),
}The sitemap is written to the build output directory as sitemap.xml after prerendering and before precompression.
logger.level
Use logger.level to control internal Solas logging.
Default: info
Valid values are debug, info, warn, error, and fatal.
debug: show everythinginfo: the defaultwarn: only warnings and errorserror: only errorsfatal: only fatal errors
This is mainly useful when debugging framework behaviour such as routing and prerendering. It is for Solas internals, not your app's general-purpose logging, and it does not control top-level CLI status output such as build and preview progress messages.
export default defineConfig({
plugins: [
solas({
logger: {
level: process.env.NODE_ENV === 'production' ? 'fatal' : 'info',
},
}),
],
})Scripts
Add scripts to your app:
{
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
}
}These defaults assume either runtime: 'node' or runtime: 'auto' while running Vite under Node. If you set runtime: 'bun', or let runtime: 'auto' resolve to Bun by running Vite through Bun, use the Bun-backed commands shown in the runtime section above.
Commands
vite devstarts the development server.vite buildcreates a production build. Solas finalizes that build by prerendering configured routes, writing the runtime manifest, generatingsitemap.xmlwhen enabled, and precompressing output when enabled.vite previewserves the built app for local verification.
Security
CSRF
Solas protects server actions and +endpoint handlers against CSRF.
Server actions are always POST requests. +endpoint handlers are protected on browser-initiated POST, PUT, PATCH, and DELETE requests.
By default, browser requests must be same-origin.
When available, Solas checks browser provenance using:
Sec-Fetch-SiteOriginReferer
Solas also considers the effective request origin when your app is behind a proxy by using X-Forwarded-Host, X-Forwarded-Proto, and config.url when present.
If you need to allow a trusted third-party browser POST source, configure it explicitly:
export default defineConfig({
plugins: [
solas({
trustedOrigins: ['https://payments.example.com'],
}),
],
})Requests from non-browser callers that do not send browser provenance headers are allowed by default, so typical server-to-server integrations and webhooks continue to work.
Cookie-backed app mutations should keep the default same-origin protection and only use trustedOrigins for narrowly scoped integrations that genuinely need cross-origin browser submissions.
