This file provides guidance to Claude Code (claude.ai/code) and other Agents when working with code in this repository.
A full-stack .NET 10 + React 19 + Vite template combining ServiceStack backend with React static site generation. Uses ASP.NET Core Identity for auth, OrmLite for application data, and Entity Framework Core for Identity data management.
# Start both .NET and Vite dev servers (from project root)
dotnet watch
# After making changes to C# DTOs restart .NET before regenerating TypeScript DTOs by running:
cd MyApp.Client && npm run dtos# Build frontend (TypeScript + Vite)
cd MyApp.Client && npm run build
# Build backend (.NET)
dotnet build
# Build Tailwind CSS for Razor Pages
cd MyApp && npm run ui:build# Frontend tests (Vitest)
cd MyApp.Client && npm run test # Watch mode
cd MyApp.Client && npm run test:ui # UI mode
cd MyApp.Client && npm run test:run # Single run
# Backend tests (NUnit)
dotnet test# Run all migrations (both EF Core and OrmLite)
cd MyApp && npm run migrate
# Entity Framework migrations (for changes to Identity tables)
dotnet ef migrations add MigrationName
dotnet ef database update
# Revert last migration
cd MyApp && npm run revert:last
# Drop and re-run last migration (useful during development)
cd MyApp && npm run rerun:last# Create new AutoQuery CRUD feature with TypeScript data model
npx okai init Table
# Regenerate C# AutoQuery APIs and DB migration from .d.ts model
npx okai Table.d.ts
# Remove AutoQuery feature and all generated code
npx okai rm Table.d.tsDevelopment Mode:
dotnet watchfrom MyApp starts .NET (port 5001) and Vite dev server (port 5173), accessible viahttps://localhost:5001- ASP.NET Core proxies requests to Vite dev server via
NodeProxy(configured in Program.cs) - Hot Module Replacement (HMR) enabled via WebSocket proxying using
MapNotFoundToNode,MapViteHmr,RunNodeProcess,MapFallbackToNodein Program.cs
Production Mode:
- Vite builds React app to
MyApp.Client/dist/, which is copied toMyApp/wwwroot/when published - ASP.NET Core serves static files directly from
wwwroot- no Node.js required - Fallback to
index.htmlfor client-side routing
AppHost uses .NET's IHostingStartup pattern to split configuration across multiple files in MyApp/:
- Configure.AppHost.cs - Main ServiceStack AppHost registration
- Configure.Auth.cs - ServiceStack AuthFeature with ASP.NET Core Identity integration
- Configure.AutoQuery.cs - AutoQuery features and audit events
- Configure.Db.cs - Database setup (OrmLite for app data, EF Core for Identity)
- Configure.Db.Migrations.cs - Runs OrmLite and EF DB Migrations and creates initial users
- Configure.BackgroundJobs.cs - Background job processing
- Configure.HealthChecks.cs - Health monitoring endpoint
This pattern keeps Program.cs clean and separates concerns. Each Configure.*.cs file is auto-registered via [assembly: HostingStartup] attribute.
MyApp/ # .NET Backend (hosts both .NET and Vite React)
├── Configure.*.cs # Modular startup configuration
├── Migrations/ # EF Core Identity migrations + OrmLite app migrations
├── Pages/ # Identity Auth Razor Pages
└── wwwroot/ # Production static files (from MyApp.Client/dist)
MyApp.Client/ # React Frontend
├── src/
│ ├── lib/
│ │ ├── dtos.ts # Auto-generated from C# (via `npm run dtos`)
│ │ ├── gateway.ts # ServiceStack JsonServiceClient
│ │ └── utils.ts # Utility functions
│ ├── components/ # React components
│ └── styles/ # Tailwind CSS
└── vite.config.ts # Vite config for dev mode
MyApp.ServiceModel/ # DTOs & API contracts
├── *.cs # C# Request/Response DTOs
├── api.d.ts # TypeScript data models Schema
└── *.d.ts # TypeScript data models for okai code generation
MyApp.ServiceInterface/ # Service implementations
├── Data/ # EF Core DbContext and Identity models
└── *Services.cs # ServiceStack service implementations
MyApp.Tests/ # .NET tests (NUnit)
├── IntegrationTest.cs # API integration tests
└── MigrationTasks.cs # Migration task runner
config/
└── deploy.yml # Kamal deployment settings
.github/
└── workflows/
├── build.yml # CI build and test
├── build-container.yml # Container image build
└── release.yml # Production deployment with Kamal
Dual ORM Strategy:
- OrmLite: All application data (faster, simpler, typed POCO ORM)
- Entity Framework Core: ASP.NET Core Identity tables only (Users, Roles, etc.)
Both use the same SQLite database by default (App_Data/app.db). Connection string in appsettings.json.
Migration Files:
MyApp/Migrations/20240301000000_CreateIdentitySchema.cs- EF Core migration for IdentityMyApp/Migrations/Migration1000.cs- OrmLite migration for app tables (e.g., Booking)
Run npm run migrate to execute both.
- ASP.NET Core Identity handles user registration/login via Razor Pages at
/Identity/*routes - ServiceStack AuthFeature integrates with Identity via
IdentityAuth.For<ApplicationUser>()in Configure.Auth.cs - Custom claims added via
AdditionalUserClaimsPrincipalFactoryandCustomUserSession - ServiceStack services use
[ValidateIsAuthenticated]and[ValidateHasRole]attributes for authorization (see Bookings.cs)
ServiceStack APIs adopt a DTOs-first approach utilizing message-based APIs. To create ServiceStack APIs create all related DTOs used in the API (aka Service Contracts) into a single file in the MyApp.ServiceModel project, e.g:
//MyApp.ServiceModel/Bookings.cs
public class GetBooking : IGet, IReturn<GetBookingResponse>
{
[ValidateGreaterThan(0)]
public int Id { get; set; }
}
public class GetBookingResponse
{
public Booking? Result { get; set; }
public ResponseStatus? ResponseStatus { get; set; }
}The response type of an API should be specified in the IReturn<Response> marker interface. APIs which don't return a response should implement IReturnVoid instead.
By convention, APIs return single results in a T? Result property, APIs returns multiple results of the same type in a List<T> Results property. Otherwise APIs returning results of different types should use intuitive property names in a flat structured Response DTO for simplicity.
These C# Server DTOs are used to generate the TypeScript dtos.ts.
Any API Errors are automatically populated in the ResponseStatus property, inc. Declarative Validation Attributes like [ValidateGreaterThan] and [ValidateNotEmpty] which validate APIs and return any error responses in ResponseStatus.
The Type Validation Attributes below should be used to protect APIs:
[ValidateIsAuthenticated]- Only Authenticated Users[ValidateIsAdmin]- Only Admin Users[ValidateHasRole]- Only Authenticated Users assigned with the specified role[ValidateApiKey]- Only Users with a valid API Key
//MyApp.ServiceModel/Bookings.cs
[ValidateHasRole("Employee")]
public class CreateBooking : ICreateDb<Booking>, IReturn<IdResponse>
{
//...
}APIs have a primary HTTP Method which if not specified uses HTTP POST. Use IGet, IPost, IPut, IPatch or IDelete to change the HTTP Verb except for AutoQuery APIs which have implied verbs for each CRUD operation.
ServiceStack API implementations should be added to MyApp.ServiceInterface/:
//MyApp.ServiceInterface/BookingServices.cs
public class BookingServices(IAutoQueryDb autoquery) : Service
{
public object Any(GetBooking request)
{
return new GetBookingResponse {
Result = base.Db.SingleById<Booking>(request.Id)
?? throw HttpError.NotFound("Booking does not exist")
};
}
// Example of overriding an AutoQuery API with a custom implementation
public async Task<object> Any(QueryBookings request)
{
using var db = autoQuery.GetDb(request, base.Request);
var q = autoQuery.CreateQuery(request, base.Request, db);
return await autoQuery.ExecuteAsync(request, q, base.Request, db);
}
}APIs can be implemented with sync or async methods using Any or its primary HTTP Method e.g. Get, Post.
The return type of an API implementation does not change behavior however returning object is recommended so its clear the Request DTO IReturn<Response> interface defines the APIs Response type and Service Contract.
The ServiceStack Service base class has convenience properties like Db to resolve an Open IDbConnection for that API and base.Request to resolve the IRequest context. All other dependencies required by the API should use constructor injection in a Primary Constructor.
A ServiceStack API typically returns the Response DTO defined in its Request DTO IReturn<Response> or an Error but can also return any raw custom Return Type like string, byte[], Stream, IStreamWriter, HttpResult and HttpError.
ServiceStack's AutoQuery generates full CRUD APIs from declarative request DTOs. Example in Bookings.cs:
QueryBookings : QueryDb<Booking>→ GET /api/QueryBookings with filtering/sorting/pagingCreateBooking : ICreateDb<Booking>→ POST /api/CreateBookingUpdateBooking : IPatchDb<Booking>→ PATCH /api/UpdateBookingDeleteBooking : IDeleteDb<Booking>→ DELETE /api/DeleteBooking
No service implementation required - AutoQuery handles it. Audit fields (CreatedBy, ModifiedBy, etc.) auto-populated via [AutoApply(Behavior.AuditCreate)] attributes.
After changing C# DTOs in MyApp.ServiceModel/, restart the .NET Server then run:
cd MyApp.Client && npm run dtosThis calls ServiceStack's /types/typescript endpoint and updates dtos.ts with type-safe client DTOs. The Vite dev server auto-reloads.
The npx okai tool generates C# AutoQuery APIs and migrations from TypeScript data models (.d.ts files):
- TypeScript data model (
MyApp.ServiceModel/Bookings.d.ts) defines the entity with decorators - C# AutoQuery APIs (
MyApp.ServiceModel/Bookings.cs) - auto-generated CRUD request/response DTOs - C# OrmLite migration (
MyApp/Migrations/Migration1000.cs) - auto-generated schema creation
This enables rapid prototyping: edit the .d.ts model, run npx okai Bookings.d.ts, then npm run migrate.
Important: The .d.ts files use special decorators (e.g., @validateHasRole, @autoIncrement) that map to C# attributes and .NET Types. The valid schema for these is defined in api.d.ts. Reference Bookings.d.ts for examples.
C# AutoQuery APIs allow creating queryable C# APIs for RDBMS Tables with just a Request DTO definition, e.g:
public class QueryBookings : QueryDb<Booking>
{
public int? Id { get; set; }
public decimal? MinCost { get; set; }
public List<decimal>? CostBetween { get; set; }
public List<int>? Ids { get; set; }
}It uses these conventions to determine the behavior of each property filter:
ImplicitConventions = new() {
{"%Above%", "{Field} > {Value}"},
{"Begin%", "{Field} > {Value}"},
{"%Beyond%", "{Field} > {Value}"},
{"%Over%", "{Field} > {Value}"},
{"%OlderThan", "{Field} > {Value}"},
{"%After%", "{Field} > {Value}"},
{"OnOrAfter%", "{Field} >= {Value}"},
{"%From%", "{Field} >= {Value}"},
{"Since%", "{Field} >= {Value}"},
{"Start%", "{Field} >= {Value}"},
{"%Higher%", "{Field} >= {Value}"},
{"Min%", "{Field} >= {Value}"},
{"Minimum%", "{Field} >= {Value}"},
{"Behind%", "{Field} < {Value}"},
{"%Below%", "{Field} < {Value}"},
{"%Under%", "{Field} < {Value}"},
{"%Lower%", "{Field} < {Value}"},
{"%Before%", "{Field} < {Value}"},
{"%YoungerThan", "{Field} < {Value}"},
{"OnOrBefore%", "{Field} < {Value}"},
{"End%", "{Field} < {Value}"},
{"Stop%", "{Field} < {Value}"},
{"To%", "{Field} < {Value}"},
{"Until%", "{Field} < {Value}"},
{"Max%", "{Field} < {Value}"},
{"Maximum%", "{Field} < {Value}"},
{"%GreaterThanOrEqualTo%", "{Field} >= {Value}"},
{"%GreaterThan%", "{Field} > {Value}"},
{"%LessThan%", "{Field} < {Value}"},
{"%LessThanOrEqualTo%", "{Field} < {Value}"},
{"%NotEqualTo", "{Field} <> {Value}"},
{"Like%", "UPPER({Field}) LIKE UPPER({Value})"},
{"%In", "{Field} IN ({Values})"},
{"%Ids", "{Field} IN ({Values})"},
{"%Between%", "{Field} BETWEEN {Value1} AND {Value2}"},
{"%HasAll", "{Value} & {Field} = {Value}"},
{"%HasAny", "{Value} & {Field} > 0"},
{"%IsNull", "{Field} IS NULL"},
{"%IsNotNull", "{Field} IS NOT NULL"},
};Each convention key includes % wildcards to define where a DataModel field names can appear, either as a Prefix, Suffix or both. The convention value describes the SQL filter that gets applied to the query when the property is populated.
Properties that matches a DataModel field performs an exact query {Field} = {Value}, e.g:
const api = client.api(new QueryBookings({ id:1 }))As MinCost matches the "Min%" convention it applies the Cost >= 100 filter to the query:
const api = client.api(new QueryBookings({ minCost:100 }))As CostBetween matches the "%Between%" convention it applies the Cost BETWEEN 100 AND 200 filter to the query:
const api = client.api(new QueryBookings({ costBetween:[100,200] }))AutoQuery also matches on pluralized fields where Ids matches Id and applies the Id IN (1,2,3) filter:
const api = client.api(new QueryBookings({ ids:[1,2,3] }))Multiple Request DTO properties applies multiple AND filters, e.g:
const api = client.api(new QueryBookings({ minCost:100, ids:[1,2,3] }))Applies the (Cost >= 100) AND (Id IN (1,2,3)) filter.
Frontend code imports from lib/gateway.ts:
import { client } from '@/lib/gateway'
import { QueryBookings } from '@/lib/dtos'
const response = await client.api(new QueryBookings())The client is a configured JsonServiceClient pointing to /api (proxied to .NET backend).
All .NET APIs are accessible by Request DTOs which implement either a IReturn<ResponseType> a IReturnVoid interface which defines the API Response, e.g:
export class Hello implements IReturn<HelloResponse>, IGet
{
public name: string;
public constructor(init?: Partial<Hello>) { (Object as any).assign(this, init); }
}
export class HelloResponse
{
public result: string;
public constructor(init?: Partial<HelloResponse>) { (Object as any).assign(this, init); }
}Inside a React Component use useClient() to resolve a Service Client. The ApiResult can hold loading, failed and successful API Response states, e.g:
type Props = { value: string }
export default ({ value }:Props) => {
const [name, setName] = useState(value)
const client = useClient()
const [api, setApi] = useState<ApiResult<HelloResponse>>(new ApiResult())
useEffect(() => {
(async () => {
setApi(new ApiResult())
setApi(await client.api(new Hello({ name })))
})()
}, [name])
return (<div>
<TextInput id="name" label="API Example" value={name} onChange={setName} />
{api.error
? <div className="text-red-500">{api.error.message}</div>
: api.succeeded
? <div className="text-gray-900">{api.response.result}</div>
: <div>loading...</div>}
</div>)
}All client api, apiVoid and apiForm methods never throws exceptions - it always returns an ApiResult<T> which contains either a response for successful responses or an error with a populated ResponseStatus, as such using try/catch around client.api* calls is always wrong as it implies it would throw an Exception, when it never does.
The examples below show typical usage:
The api and apiVoid APIs return an ApiResult<Response> which holds both successful and failed API Responses:
const api = await client.api(new Hello({ name }))
if (api.succeeded) {
console.log(`The API succeeded:`, api.response)
} else if (api.error) {
console.log(`The API failed:`, api.error)
}The apiForm API can use a HTML Form's FormData for its Request Body together with an APIs empty Request DTO, e.g:
const submit = async (e: React.FormEvent) => {
const form = e.currentTarget as HTMLFormElement
const api = await client.apiForm(new CreateContact(), new FormData(form))
if (api.succeeded) {
console.log(`The API succeeded:`, api.response)
} else if (api.error) {
console.log(`The API failed:`, api.error)
}
}Using apiForm is required for multipart/form-data File Uploads.
The @servicestack/react component library have several components to simplify UI generation.
The <AutoForm> Component can be used to render an API validation bound form for any Request DTO.
import { AutoForm, AutoCreateForm, AutoEditForm, HtmlFormat } from '@servicestack/react'
function GenericFormExample() {
const [results, setResults] = useState<Booking[]|undefined>()
const onSuccess = (response:QueryResponse<Booking>) => {
setResults(response.results)
}
return (
<AutoForm panelClass="mx-auto max-w-3xl" type="QueryBookings" onSuccess={onSuccess} />
{results && <HtmlFormat value={results} />}
)
}The <AutoCreateForm> can be used with an AutoQuery CRUD ICreateDb<T> DTO to render a create entity form.
<AutoCreateForm type="CreateBooking" formStyle="card" />The <AutoEditForm> can be used with an AutoQuery CRUD IPatchDb<T> or IUpdateDb<T> DTO to render an update form.
The deleteType can be set to use an IDeleteDb<T> DTO to enable delete functionality.
function EditFormExample({ booking }:{ Booking:booking }) {
return (
<AutoEditForm
value={booking}
type="UpdateBooking"
deleteType="DeleteBooking"
heading="Change an existing Room Booking"
subHeading="Manage reservations for MyApp hotels."
formStyle="card"
/>)
}The ApiStateContext can be used to inject an APIs Error ResponseStatus down to all @servicestack/react Input components.
import {ErrorSummary, TextInput, PrimaryButton, useClient, ApiStateContext} from "@servicestack/react"
function CustomFormExample() {
const client = useClient()
const [userName, setUserName] = useState<string|undefined>()
const [password, setPassword] = useState<string|undefined>()
const onSubmit = async (e:SyntheticEvent<HTMLFormElement>) => {
e.preventDefault()
const api = await client.api(new Authenticate({ provider: 'credentials', userName, password }))
if (api.succeeded) {
console.log('Signed In!', api.response)
} else if (api.error.errorCode === 'Unauthorized') {
console.log('Sign In failed:', api.error.message)
}
}
return (<ApiStateContext.Provider value={client}>
<form onSubmit={onSubmit}>
<ErrorSummary except="userName,password,rememberMe"/>
<div>
<TextInput id="userName" help="Email you signed up with" autoComplete="email"
value={userName} onChange={setUserName}/>
<TextInput id="password" type="password" help="6 characters or more"
value={password} onChange={setPassword}/>
<PrimaryButton>Log in</PrimaryButton>
</div>
</form>
</ApiStateContext.Provider>)
}All field errors are displayed next to their Input component all other API errors are displayed with the <ErrorSummary> component. Use except to avoid displaying field errors which are already displayed next to their associated input component.
If needed, the error ResponseStatus can be passed to components using its status property.
/api/*→ ServiceStack services/Identity/*→ ASP.NET Core Identity Razor Pages/ui/*→ ServiceStack API Explorer/admin-ui/*→ ServiceStack Admin UI (requires Admin role)/types/typescript→ ServiceStack .NET API TypeScript DTOs (for dtos.ts)- All other routes → React SPA (via fallback in dev/prod)
The template includes Razor Pages for Identity UI (/Identity routes) that coexist with the React SPA. These use Tailwind CSS compiled from MyApp/tailwind.input.css to MyApp/wwwroot/css/app.css.
KAMAL_DEPLOY_HOST- Production hostname for deployment
Configured in Configure.BackgroundJobs.cs using BackgroundsJobFeature. Jobs are commands that implement IAsyncCommand<T>.
- Start dev servers:
dotnet watch(starts both .NET and Vite) - Make backend changes: Edit C# files in
MyApp.ServiceModelorMyApp.ServiceInterface - Restart .NET Server
- Regenerate DTOs:
cd MyApp.Client && npm run dtos - Make frontend changes: Edit React files in
MyApp.Client/src - Add new CRUD feature:
npx okai init Feature- Edit
MyApp.ServiceModel/Feature.d.ts npx okai Feature.d.tsnpm run migrate
Docs: AutoQuery Dev Workflow
/admin-ui- ServiceStack Admin UI (database, users, API explorer)/admin-ui/users- User management (requires Admin role)/up- Health check endpoint
GitHub Actions workflows in .github/workflows/ uses Kamal for Deployments:
build.yml- CI build and testbuild-container.yml- Docker image buildrelease.yml- Kamal deployment to production
Configure KAMAL_DEPLOY_HOST in GitHub secrets for your hostname. Kamal config in config/deploy.yml derives service names from repository name.