ShiftAPI is a Go framework that generates an OpenAPI 3.1 spec from your handler types at runtime, then uses a Vite or Next.js plugin to turn that spec into a fully-typed TypeScript client — so your frontend stays in sync with your API automatically.
Go structs ──→ OpenAPI 3.1 spec ──→ TypeScript types ──→ Typed fetch client
(compile time) (runtime) (build time) (your frontend)
Scaffold a full-stack app (Go + React, Svelte, or Next.js):
npm create shiftapi@latestOr add ShiftAPI to an existing Go project:
go get github.com/fcjr/shiftapiShiftAPI requires Go 1.27 or later. Route registration uses generic methods, which landed in that release.
package main
import (
"log""net/http""github.com/fcjr/shiftapi"
)
typePersonstruct {
Namestring`json:"name" validate:"required"`
}
typeGreetingstruct {
Hellostring`json:"hello"`
}
funcgreet(r*http.Request, in*Person) (*Greeting, error) {
return&Greeting{Hello: in.Name}, nil
}
funcmain() {
api:=shiftapi.New(shiftapi.WithInfo(shiftapi.Info{
Title: "Greeter API",
Version: "1.0.0",
}))
api.Handle("POST /greet", greet)
log.Println("listening on :8080")
log.Fatal(shiftapi.ListenAndServe(":8080", api))
// interactive docs at http://localhost:8080/docs
}That's it. ShiftAPI reflects your Go types into an OpenAPI 3.1 spec at /openapi.json and serves interactive docs at /docs — no code generation step, no annotations.
Handle is a generic method on *API and *Group, so it captures your request and response types at compile time. Every HTTP method goes through the same call — struct tags discriminate query params (query:"..."), HTTP headers (header:"..."), body fields (json:"..."), and form fields (form:"..."). For routes without input, use _ struct{}.
// POST with body — input is decoded and passed as *CreateUserapi.Handle("POST /users", func(r*http.Request, in*CreateUser) (*User, error) {
returndb.CreateUser(r.Context(), in)
}, shiftapi.WithStatus(http.StatusCreated))
// GET without input — use _ struct{}api.Handle("GET /users/{id}", func(r*http.Request, _struct{}) (*User, error) {
returndb.GetUser(r.Context(), r.PathValue("id"))
})Use path tags to declare typed path parameters. They are parsed from the URL, validated, and documented in the OpenAPI spec automatically:
typeGetUserInputstruct {
IDint`path:"id" validate:"required,gt=0"`
}
api.Handle("GET /users/{id}", func(r*http.Request, inGetUserInput) (*User, error) {
returndb.GetUser(r.Context(), in.ID) // in.ID is already an int
})Supports the same scalar types as query params: string, bool, int*, uint*, float*. Use validate:"uuid" on a string field for UUID path params. Parse errors return 400; validation failures return 422.
Path parameters are always required and always scalar — pointers and slices on path-tagged fields panic at registration time. You can still use r.PathValue("id") directly for routes that don't need typed path params.
Define a struct with query tags. Query params are parsed, validated, and documented in the OpenAPI spec automatically.
typeSearchQuerystruct {
Qstring`query:"q" validate:"required"`Pageint`query:"page" validate:"min=1"`Limitint`query:"limit" validate:"min=1,max=100"`
}
api.Handle("GET /search", func(r*http.Request, inSearchQuery) (*Results, error) {
returndoSearch(in.Q, in.Page, in.Limit), nil
})Supports string, bool, int*, uint*, float* scalars, *T pointers for optional params, and []T slices for repeated params (e.g. ?tag=a&tag=b). Parse errors return 400; validation failures return 422.
For handlers that need both query parameters and a request body, combine them in a single struct — fields with query tags become query params, fields with json tags become the body:
typeCreateInputstruct {
DryRunbool`query:"dry_run"`Namestring`json:"name"`
}
api.Handle("POST /items", func(r*http.Request, inCreateInput) (*Result, error) {
returncreateItem(in.Name, in.DryRun), nil
})Define a struct with header tags. Headers are parsed, validated, and documented in the OpenAPI spec automatically — just like query params.
typeAuthInputstruct {
Tokenstring`header:"Authorization" validate:"required"`Qstring`query:"q"`
}
api.Handle("GET /search", func(r*http.Request, inAuthInput) (*Results, error) {
// in.Token parsed from the Authorization header// in.Q parsed from ?q= query paramreturndoSearch(in.Token, in.Q), nil
})Supports string, bool, int*, uint*, float* scalars and *T pointers for optional headers. Parse errors return 400; validation failures return 422. Header, query, and body fields can be freely combined in one struct.
Use form tags to declare file upload endpoints. The form tag drives OpenAPI spec generation — the generated TypeScript client gets the correct multipart/form-data types automatically. At runtime, the request body is parsed via ParseMultipartForm and form-tagged fields are populated.
typeUploadInputstruct {
File*multipart.FileHeader`form:"file" validate:"required"`Titlestring`form:"title" validate:"required"`Tagsstring`query:"tags"`
}
api.Handle("POST /upload", func(r*http.Request, inUploadInput) (*Result, error) {
f, err:=in.File.Open()
iferr!=nil {
returnnil, fmt.Errorf("failed to open file: %w", err)
}
deferf.Close()
// read from f, save to disk/S3/etc.return&Result{Filename: in.File.Filename, Title: in.Title}, nil
})*multipart.FileHeader— single file (type: string, format: binaryin OpenAPI,File | Blob | Uint8Arrayin TypeScript)[]*multipart.FileHeader— multiple files (type: array, items: {type: string, format: binary})- Scalar types with
formtag — text form fields querytags work alongsideformtags- Mixing
jsonandformtags on the same struct panics at registration time
Restrict accepted file types with the accept tag. This validates the Content-Type at runtime (returns 400 if rejected) and documents the constraint in the OpenAPI spec via the encoding map:
typeImageUploadstruct {
Avatar*multipart.FileHeader`form:"avatar" accept:"image/png,image/jpeg" validate:"required"`
}The default max upload size is 32 MB. Configure it with WithMaxUploadSize:
api:=shiftapi.New(shiftapi.WithMaxUploadSize(64<<20)) // 64 MBBuilt-in validation via go-playground/validator. Struct tags are enforced at runtime and reflected into the OpenAPI schema.
typeCreateUserstruct {
Namestring`json:"name" validate:"required,min=2,max=50"`Emailstring`json:"email" validate:"required,email"`Ageint`json:"age" validate:"gte=0,lte=150"`Rolestring`json:"role" validate:"oneof=admin user guest"`
}Invalid requests return 422 with per-field errors:
{
"message": "validation failed",
"errors": [
{ "field": "Name", "message": "this field is required" },
{ "field": "Email", "message": "must be a valid email address" }
]
}Supported tags: required, email, url/uri, uuid, datetime, min, max, gte, lte, gt, lt, len, oneof — all mapped to their OpenAPI equivalents (format, minimum, maxLength, enum, etc.). Use WithValidator() to supply a custom validator instance.
Use Group to create a sub-router with a shared path prefix and options. Groups can be nested:
v1:=api.Group("/api/v1",
shiftapi.WithError[*RateLimitError](http.StatusTooManyRequests),
shiftapi.WithMiddleware(auth),
)
v1.Handle("GET /users", listUsers) // GET /api/v1/usersv1.Handle("POST /users", createUser) // POST /api/v1/usersadmin:=v1.Group("/admin",
shiftapi.WithError[*ForbiddenError](http.StatusForbidden),
shiftapi.WithMiddleware(adminOnly),
)
admin.Handle("GET /stats", getStats) // GET /api/v1/admin/statsGroups are also how you split route registration across files. Write a function
that takes a *shiftapi.Group and let the caller decide where it mounts:
// users.gofuncUserRoutes(g*shiftapi.Group) {
g.Handle("GET /users", listUsers)
g.Handle("POST /users", createUser)
}
// main.goUserRoutes(api.Group("/api/v1"))Pass api.Group("") to mount at the root. These functions should take
*shiftapi.Group rather than *shiftapi.API. Go does not allow generic methods
on interfaces, so no single interface covers both types, and a group works
everywhere an API does.
Use WithMiddleware to apply standard HTTP middleware at any level — API, group, or route:
api:=shiftapi.New(
shiftapi.WithMiddleware(cors, logging), // all routes
)
v1:=api.Group("/api/v1",
shiftapi.WithMiddleware(auth), // group routes
)
v1.Handle("GET /admin", getAdmin,
shiftapi.WithMiddleware(adminOnly), // single route
)Middleware resolves from outermost to innermost: API → parent Group → child Group → Route → handler. Within a single WithMiddleware(a, b) call, the first argument wraps outermost.
Use NewContextKey, SetContext, and FromContext to pass typed data from middleware to handlers — no untyped context.Value keys or type assertions needed:
varuserKey= shiftapi.NewContextKey[User]("user")
// Middleware stores the value:funcauthMiddleware(next http.Handler) http.Handler {
returnhttp.HandlerFunc(func(w http.ResponseWriter, r*http.Request) {
user, err:=authenticate(r)
iferr!=nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, shiftapi.SetContext(r, userKey, user))
})
}
// Handler retrieves it — fully typed, no assertion needed:authed.Handle("GET /me", func(r*http.Request, _struct{}) (*Profile, error) {
user, ok:=shiftapi.FromContext(r, userKey)
if!ok {
returnnil, fmt.Errorf("missing user context")
}
return&Profile{Name: user.Name}, nil
})Each ContextKey has pointer identity, so two keys for the same type never collide. The type parameter ensures SetContext and FromContext agree on the value type at compile time.
Use WithError to declare that a handler may return a specific error type at a given HTTP status code. Works at any level — API, group, or route:
api:=shiftapi.New(
shiftapi.WithError[*AuthError](http.StatusUnauthorized), // all routes
)
api.Handle("GET /users/{id}", getUser,
shiftapi.WithError[*NotFoundError](http.StatusNotFound), // single route
)The error type must implement error — its struct fields are reflected into the OpenAPI schema. At runtime, if the handler returns a matching error (via errors.As), it is serialized as JSON with the declared status code. Wrapped errors work automatically. Unrecognized errors return 500.
Customize the default 400/500 responses with WithBadRequestError and WithInternalServerError:
api:=shiftapi.New(
shiftapi.WithBadRequestError(func(errerror) *MyBadRequest {
return&MyBadRequest{Code: "BAD_REQUEST", Message: err.Error()}
}),
shiftapi.WithInternalServerError(func(errerror) *MyServerError {
log.Error("unhandled", "err", err)
return&MyServerError{Code: "INTERNAL_ERROR", Message: "internal server error"}
}),
)Every route automatically includes 400, 422 (ValidationError), and 500 responses in the generated OpenAPI spec.
WithError and WithMiddleware are Option values — they work at all three levels. Use ComposeOptions to bundle them into reusable options:
funcWithAuth() shiftapi.Option {
returnshiftapi.ComposeOptions(
shiftapi.WithMiddleware(authMiddleware),
shiftapi.WithError[*AuthError](http.StatusUnauthorized),
)
}For level-specific composition (mixing shared and level-specific options), use ComposeAPIOptions, ComposeGroupOptions, or ComposeHandleOptions:
createOpts:=shiftapi.ComposeHandleOptions(
shiftapi.WithStatus(http.StatusCreated),
shiftapi.WithError[*ConflictError](http.StatusConflict),
)
api.Handle("POST /users", createUser, createOpts)Add OpenAPI summaries, descriptions, and tags per route:
api.Handle("POST /greet", greet,
shiftapi.WithRouteInfo(shiftapi.RouteInfo{
Summary: "Greet a person",
Description: "Returns a personalized greeting.",
Tags: []string{"greetings"},
}),
)API implements http.Handler, so it works with any middleware, httptest, and ServeMux mounting:
// middlewarewrapped:=loggingMiddleware(corsMiddleware(api))
http.ListenAndServe(":8080", wrapped)
// mount under a prefixmux:=http.NewServeMux()
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api))ShiftAPI ships npm packages for the frontend:
shiftapi— CLI and codegen core. Extracts the OpenAPI spec from your Go server, generates TypeScript types via openapi-typescript, and writes a pre-configured openapi-fetch client.@shiftapi/vite-plugin— Vite plugin for dev-time HMR, proxy, and Go server management.@shiftapi/next— Next.js integration with the same DX (webpack/Turbopack aliases, rewrites proxy, Go server management).
shiftapi.config.ts (project root):
import{defineConfig}from"shiftapi";exportdefaultdefineConfig({server: "./cmd/server",// Go entry point});npm install shiftapi @shiftapi/vite-plugin// vite.config.tsimportshiftapifrom"@shiftapi/vite-plugin";import{defineConfig}from"vite";exportdefaultdefineConfig({plugins: [shiftapi()],});npm install shiftapi @shiftapi/next// next.config.tsimporttype{NextConfig}from"next";import{withShiftAPI}from"@shiftapi/next";constnextConfig: NextConfig={};exportdefaultwithShiftAPI(nextConfig);import{client}from"@shiftapi/client";const{ data }=awaitclient.GET("/health");// data: { ok?: boolean }const{data: greeting}=awaitclient.POST("/greet",{body: {name: "frank"},});// body and response are fully typed from your Go structsconst{data: results}=awaitclient.GET("/search",{params: {query: {q: "hello",page: 1,limit: 10}},});// query params are fully typed too — { q: string, page?: number, limit?: number }const{data: upload}=awaitclient.POST("/upload",{body: {file: newFile(["content"],"doc.txt"),title: "My Doc"},params: {query: {tags: "important"}},});// file uploads are typed as File | Blob | Uint8Array — generated from format: binary in the specconst{data: authResults}=awaitclient.GET("/search",{params: {query: {q: "hello"},header: {Authorization: "Bearer token"},},});// header params are fully typed as wellIn dev mode the plugins start the Go server, proxy API requests, watch .go files, and regenerate types on changes.
CLI usage (without Vite/Next.js):
shiftapi prepareThis extracts the spec and generates .shiftapi/client.d.ts and .shiftapi/client.js. Useful in postinstall scripts or CI.
Config options:
| Option | Default | Description |
|---|---|---|
server | (required) | Go entry point (e.g. "./cmd/server") |
baseUrl | "/" | Fallback base URL for the API client |
url | "http://localhost:8080" | Go server address for dev proxy |
For production, set VITE_SHIFTAPI_BASE_URL (Vite) or NEXT_PUBLIC_SHIFTAPI_BASE_URL (Next.js) to point at your API host. The plugins automatically update tsconfig.json with the required path mapping for IDE autocomplete.
This is a pnpm + Turborepo monorepo.
pnpm install # install dependencies
pnpm build # build all packages
pnpm dev # start example Vite + Go app
pnpm test# run all testsGo tests can also be run directly:
go test -count=1 -tags shiftapidev ./...Made with love for types at the Recurse Center