Full-stack frameworks

SolidStart

Add a SolidStart API route and call it from queries, actions, or the browser.

On this page

Serve your API from a SolidStart v2 API route, then call it with browser Fetch or a Solid Router query or action.

The API route makes the contract available to other apps over HTTP. If an operation is used only by your SolidStart UI, you can use a server-only query with a local client instead.

Start with an existing SolidStart v2 application whose vite.config.ts already runs with the SolidStart Vite plugin. SolidStart v2 requires Node.js 24 or newer and Vite 8 or 9; production deployment configuration remains application-owned.

The adapter’s peer dependency is @solidjs/start ^2.0.0. It does not support the Vinxi event shape from SolidStart v1.

Keep the contract in shared code. This example declares GET /api/health and POST /api/users:

TypeScript
// src/api/contract.ts
import { defineContract, response, route, router } from '@hulla/api'
import { z } from 'zod'

const user = z.object({
  id: z.string(),
  name: z.string(),
})

export const contract = defineContract({
  basePath: '/api',
  routes: {
    health: route.get('/health', {
      responses: { 200: response.json(z.literal('ok')) },
    }),
    users: router('/users', {
      routes: {
        create: route.post('/', {
          body: z.object({ name: z.string().min(1) }),
          responses: { 201: response.json(user) },
        }),
      },
    }),
  },
})

Implement the same route tree in server code:

TypeScript
// src/api/server.ts
import { defineServer } from '@hulla/api/server'
import { contract } from './contract'

export const implementation = defineServer(contract).implement({
  health: ({ response }) => response(200, 'ok'),
  users: {
    create: ({ body, response }) =>
      response(201, { id: 'user-1', name: body.name }),
  },
})

The contract’s basePath is /api, so the catch-all route must pass requests below /api to the adapter. See contract authoring and server implementations for the complete APIs.

Create a catch-all file route and export the mounted handler only under the methods declared by the sample contract:

TypeScript
// src/routes/api/[...api].ts
import { solidStartAdapter } from '@hulla/api-solid-start'
import { implementation } from '~/api/server'

const handler = solidStartAdapter().mount(implementation)

export { handler as GET, handler as POST }

SolidStart discovers API methods from static named exports, so mount() returns one handler rather than a dynamically generated method map. The host catch-all chooses the file route; @hulla/api then matches the complete request method and URL against the mounted contract.

Export only the methods present in the mounted implementation. Static method exports are part of SolidStart’s route contract; do not export every HTTP method merely because the handler can reject unsupported requests.

The [...api].ts route covers paths below /api. If the contract also declares an operation exactly at /api, export the same handler methods from src/routes/api/index.ts.

When an API route exports GET but not HEAD, SolidStart calls the GET handler for HEAD requests. The adapter dispatches that request through the contract’s GET route and removes the response body. During native context construction, the direct request has method GET, while solidStartEvent.request retains the incoming HEAD method.

SolidStart API routes cannot expose @hulla/api’s QUERY method. mount() rejects an implementation or fragment containing one. Keep OPTIONS behavior in the route or surrounding middleware when the application needs custom CORS handling.

Start the application with its normal development command. The port may differ in your project; the examples use http://localhost:3000.

Terminal
curl -i http://localhost:3000/api/health

The route returns the declared response:

http
HTTP/1.1 200 OK
content-type: application/json

"ok"
Terminal
curl -i \
  -X POST \
  -H 'content-type: application/json' \
  -d '{"name":"Ada"}' \
  http://localhost:3000/api/users

The route returns the declared 201 variant:

http
HTTP/1.1 201 Created
content-type: application/json

{"id":"user-1","name":"Ada"}
CallerUseOwner of data flow
Browser or external servicefetchTransport() against the mounted routeHTTP route and caller
SolidStart UI readSolid Router query() + createAsync()Solid Router query cache and revalidation
SolidStart UI mutationSolid Router action() and a formSolid Router submission and revalidation
Application-local server codeServer-only query/function + inProcessTransport()The current server process

For a complete transport decision, see server and browser calls.

Browser and external HTTP calls

Create a browser-safe client when the HTTP route itself is the boundary:

TypeScript
// src/api/client.ts
import { createClient } from '@hulla/api/client'
import { fetchTransport } from '@hulla/api/fetch'
import { contract } from './contract'

export const api = createClient(contract, {
  transport: fetchTransport(),
})

An isomorphic HTTP query needs an absolute public origin during SSR because the server’s Request constructor cannot resolve a relative URL. A server-side HTTP call also does not inherit browser cookies or authorization headers; forward only the credentials the API requires. Configure an absolute baseUrl and the required CORS policy for an API on another origin.

Solid Router query and action

Solid Router’s query() and createAsync() own read caching, preload, and revalidation. Its action() owns form submission and pending/error state. Keep this page focused on the boundary; use SolidStart’s data fetching and data mutation guides for deeper cache and invalidation behavior.

TypeScript
// src/api/local.ts
import 'server-only'

import { createClient } from '@hulla/api/client'
import { inProcessTransport } from '@hulla/api/in-process'
import { contract } from './contract'
import { implementation } from './server'

export const api = createClient(contract, {
  transport: inProcessTransport(implementation),
})
TypeScript
// src/api/health.ts
import { query } from '@solidjs/router'

export const getHealth = query(async () => {
  'use server'

  const { api } = await import('./local')
  const result = await api.health()

  if (result.status !== 200) throw new Error('Health check failed')
  return result.body
}, 'health')
tsx
// src/routes/index.tsx
import { createAsync } from '@solidjs/router'
import { getHealth } from '../api/health'

export default function Home() {
  const health = createAsync(() => getHealth())
  return <main>API status: {health()}</main>
}

For a form mutation, keep the same server-only import boundary:

tsx
// src/api/users.ts
import { action, useSubmission } from '@solidjs/router'
import { Show } from 'solid-js'

export const createUser = action(async (formData: FormData) => {
  'use server'

  const { api } = await import('./local')
  const result = await api.users.create({
    body: { name: String(formData.get('name') ?? '') },
  })

  if (result.status !== 201) throw new Error('User creation failed')
  return result.body
}, 'createUser')

export function CreateUserForm() {
  const submission = useSubmission(createUser)

  return (
    <form action={createUser} method="post">
      <input name="name" />
      <button disabled={submission.pending}>
        {submission.pending ? 'Creating…' : 'Create user'}
      </button>
      <Show when={submission.error}>{(error) => <p>{error().message}</p>}</Show>
    </form>
  )
}

The 'use server' directive makes the query or action a SolidStart server function. Its dynamic import keeps the in-process client and implementation behind that server boundary. The in-process transport avoids a loopback HTTP request and does not enter the mounted API route or run SolidStart API-route middleware; it still runs the @hulla/api context, middleware, handler, and response pipeline. Use fetchTransport() when the native HTTP boundary is part of the behavior you need.

Use adapter.context() when contract handlers need SolidStart request state. The adapter exposes the native APIEvent alongside the matched contract route:

TypeScript
// src/api/server-with-context.ts
import { defineServer } from '@hulla/api/server'
import { solidStartAdapter } from '@hulla/api-solid-start'
import { contract } from './contract'

declare global {
  namespace App {
    interface RequestEventLocals {
      actor: { readonly userId: string }
    }
  }
}

const adapter = solidStartAdapter()

export const server = defineServer(contract, {
  context: adapter.context(({ request, route, solidStartEvent }) => ({
    actor: solidStartEvent.locals.actor,
    clientAddress: solidStartEvent.clientAddress,
    nativeEvent: solidStartEvent.nativeEvent,
    operation: route.key,
    request,
  })),
})

solidStartEvent contains the native APIEvent: request-scoped locals, file-route params, mutable response metadata, optional clientAddress, and the underlying H3 event. request is also exposed directly, while route remains contract-derived operation metadata. SolidStart middleware should authenticate the request and populate event.locals before the adapter constructs contract context.

Context created with adapter.context() is bound to solid-start; another adapter or inProcessTransport() cannot supply its native event and rejects the implementation.

Use onError for SolidStart-aware logging or to replace @hulla/api’s protocol-safe default response:

TypeScript
// src/routes/api/[...api].ts
import { solidStartAdapter } from '@hulla/api-solid-start'
import { implementation } from '~/api/server'

const adapter = solidStartAdapter({
  onError({ error, phase, request, route, solidStartEvent, defaultResponse }) {
    console.error(
      phase,
      request.url,
      route?.key,
      solidStartEvent.clientAddress,
      error
    )
    return defaultResponse
  },
})

const handler = adapter.mount(implementation)

export { handler as GET, handler as POST }

The hook receives the native event, request, matched contract metadata when available, error phase, and protocol-safe default Response. Return a replacement Response to customize the result, or undefined to keep the default. Model expected application failures as declared contract responses; use this hook for adapter or handler failures.

Use an implementation fragment when a route group needs separate code-splitting, middleware, deployment, or ownership boundaries. Mount that fragment from a narrower catch-all file and export only the methods declared by the fragment. See fragments and composition for the complete defineServer(contract.routes...) patterns.

Keep raw SolidStart API routes for endpoints that benefit more from SolidStart’s native Request/Response and event APIs than from a shared contract. For request-wide authentication and logging, use SolidStart middleware and translate its locals through the adapter context above.

Next, read server and browser calls to choose a transport, context and middleware for shared contract policy, and errors for declared application failures.