This release hardens the API surface against runaway clients and cleans up how translations are owned between the template and the projects built from it.
Rate limiting on expensive mutations
Mutations that a buggy or malicious client could spam — row creation, file uploads, outbound email and push messages, and paid external API calls — now carry their own rate limit.
Each procedure declares one rateLimitMiddleware with a key unique to it, following a {feature}:{action} convention:
createPresignedPost: authProcedure
.use(rateLimitMiddleware({
key: 'file:create-presigned-post',
limit: 60,
timeWindow: 60_000,
strategy: RateLimitStrategy.user,
}))
.input(CreatePresignedPostSchema)
.mutation(({ ctx, input }) => createFilePresignedPost(ctx.user.user as User, input)),
Limits are set well above any plausible human usage, so legitimate users never encounter them. They exist to stop scripts and bugs, not people.
Anonymous sign-in is limited per IP and file uploads per user, with the remaining expensive mutations covered in the same pass.
A strategy enum instead of string literals
Choosing how a limit is keyed is now a typed decision. Use RateLimitStrategy.user for authenticated procedures and RateLimitStrategy.ip for unauthenticated ones, always via the enum from @repo/trpc rather than a raw string.
Two things a rate limit is not
Not a quota. If a table can grow without bound, enforce a per-account cap as well — see
ACCOUNT_LIMITSandcreateFilePresignedPost.Not a correctness guarantee. Limits fail open when Redis is unavailable, by design. Never rely on one to protect an invariant.
Deactivated users rejected at the middleware
The tRPC auth middlewares now reject deactivated users, so a deactivated account cannot continue using a session that was issued while it was still active. Previously this depended on each procedure remembering to check.
Translation namespaces split
Translation resources are now split into two namespaces per language:
base.json— the template's own keys. In a project this file is owned upstream and is overwritten on every update, so it should never be edited there.app.json— the project's keys. Everything you add goes here.
The i18n tooling was the real cause of the confusion: it defaulted to writing base.json and scanned the template's apps/client, so in a renamed project it wrote to the wrong file and never scanned the right app. New projects now get the tooling pointed at their own client and namespace automatically.
A reminder that has not changed: always pass string literals to t() and <Trans>. The tooling extracts keys by scanning source statically, so a key passed as a variable is silently never translated. Use {{placeholders}} inside a literal key for dynamic values, and branch explicitly when the message itself depends on a value.
Adopting this release
Rate limiting, the strategy enum, and the auth middleware change are server-side and shared — they arrive when you bump the shared packages. The i18n namespace split affects your client and translation resources, so it is ported by hand. Check the adoption checklist in the release notes for the full list.