Remove a feature you don't use
Examples used to test this page: the blog and the admin MCP server, each removed from a fresh copy of the template by following the steps below literally, until verify was green. Billing was left switched off rather than removed; its section says why.
Check the first section before you delete anything: a feature that stays off without its env vars may be all you need, and switching it off leaves nothing to resolve when you merge upstream.
Switched off until you configure it
| Feature | Off when | What is left |
|---|---|---|
| Billing (Stripe) | STRIPE_SECRET_KEY is unset, along with STRIPE_WEBHOOK_SECRET, STRIPE_PRO_PRICE_ID, STRIPE_LIFETIME_PRICE_ID and STRIPE_AUTOMATIC_TAX (any of them without the secret key refuses boot) |
/pricing answers 404 (frontend/proxy.ts), the Pricing links, the landing page's plans and its billing rows and questions are hidden, Settings has no billing section, and checkout (subscription and one-time), portal and /webhooks/stripe answer 404. Account deletion never calls Stripe. Every user is on the free plan, so ItemsService.create caps each owner at FREE_PLAN_ITEM_LIMIT (100) items across their workspaces |
| The Lifetime product | STRIPE_LIFETIME_PRICE_ID is unset, with billing on |
Its card is off /pricing and the landing page, and POST /purchases/checkout answers 404 for it. Subscriptions are unaffected. Grants already recorded keep granting Pro |
| GitHub sign-in | AUTH_GITHUB_ID or AUTH_GITHUB_SECRET unset |
No button: frontend/auth.config.ts registers only configured providers |
| Google sign-in | AUTH_GOOGLE_ID or AUTH_GOOGLE_SECRET unset |
No button |
| Magic-link sign-in | The whole EMAIL_* block unset in the frontend |
The sign-in page has no email form (emailEnabled in app/signin/page.tsx). Part of the block without EMAIL_SERVER_HOST refuses boot in production |
Two features have no off switch:
- The backend's email queue.
SMTP_FROMis required at boot. WithoutSMTP_HOSTthe backend logs a warning and sends tolocalhost:1025, so every queued email fails and is retried until it runs out of attempts. - The admin MCP server. It has no key of its own: an agent connects only after an admin signs in and allows it, so with no admins nobody can connect, but the routes, the OAuth endpoints, the consent page and the backoffice screen are still there. To have no MCP server, remove it as below.
The blog has no switch either: with no posts, /blog and its links stay.
Teams and workspaces are not a feature to remove. Every user has a
personal workspace, items belong to a workspace, and account deletion,
the item cap and the frontend's item pages all read it. A product with no
teams can leave the shared-workspace UI unused: nobody gets a second
workspace or an invite unless they create one. The email queue it sends
invites through is the one above, and the invite link is built from
FRONTEND_URL.
Before you remove anything
Three rules hold for every section below.
Never edit or delete a migration that has run. Knex records every file it ran, and a missing one stops every later
npm run migratewithThe migration directory is corrupt, the following files are missing(Updating from upstream). The feature's tables go with a new migration that drops them; see Drop the tables.Delete the tests with the code. A spec of a deleted file fails to compile, and the guard specs (
route-auth.spec.ts,deletion-policy.integration.spec.ts) fail on entries for what is gone.Regenerate the contract and types after the backend change, from
backend/:npx ts-node -r tsconfig-paths/register scripts/dump-openapi.ts cp openapi.runtime.json ../openapi.json cp openapi.internal.runtime.json ../openapi.internal.json cd ../frontend && npm run generate-typesThe frontend then fails
npx tsc --noEmitwherever it still uses a removed route or type, which is a good list of what is left to delete.
Run npm run format in each half you changed, then ./verify.sh.
Drop the tables
Add a migration named with the next number on disk (look in
backend/migrations/), for examplebackend/migrations/019_drop_blog_posts.ts:import { Knex } from 'knex'; /** * Drops the blog's table, with its posts. Dropping it also drops its * indexes and its foreign key to `users`. * * `down` cannot bring the posts back, so it refuses rather than recreate an * empty table: restore a backup taken before this ran instead. */ export async function up(knex: Knex): Promise<void> { await knex.schema.dropTableIfExists('blog_posts'); } export async function down(): Promise<void> { throw new Error('019_drop_blog_posts cannot be undone: restore a backup'); }Back up production before you deploy it. The rows are gone once it runs.
Remove the table's entry from
DELETION_POLICYinbackend/src/users/deletion-policy.ts.deletion-policy.integration.spec.tscompares the entries with the migrated database, so it fails until the entry is gone.
What this means for upstream merges. Upstream keeps its migrations, so
001_initial.ts still creates the table and yours drops it; that merges
without a conflict. Three things need you:
- An upstream migration that later changes a table you dropped fails on
your database, and in verify, which migrates a fresh one. Leave the file
in place and make its
upreturn early when the table is gone, withif (!(await knex.schema.hasTable('blog_posts'))) return;. Do not delete it: once a deploy has recorded it, its absence is the corrupt-directory error. - An upstream change to a file you deleted arrives as a
deleted by usconflict.git rmthe file again, and read the change in case it touched a file you kept as well. - Upstream edits to the
POLICYorDELETION_POLICYentries you removed conflict with your removal. Keep your side.
Remove the blog
The blog is the public /blog pages, the backoffice blog screens, the
blog backend module, the blog tools on the admin MCP server and the
blog_posts table. If you are removing the admin MCP server as well, do
that first and skip the mcp-admin steps here.
Backend
- Delete
backend/src/blog/(the module, both controllers, the service, the schemas and its four specs),backend/src/common/reading-time.tsand its spec, andbackend/src/mcp-admin/mcp-admin-blog.tools.tsand its spec. backend/src/app.module.ts: remove theBlogModuleimport and its entry inimports.- The admin MCP server's blog tools.
mcp-admin/mcp-admin.module.ts: removeBlogModulefrom the imports.mcp-admin/mcp-admin-tools.service.ts: remove theBlogServiceandregisterBlogToolsimports, theblogServiceconstructor parameter, theregisterBlogTools(...)call and "blog authoring, " fromADMIN_SERVER_DESCRIPTION.mcp-admin/mcp-admin-tools.service.spec.ts: one{} as neverfewer inbuildService, the threeadmin_*_blog_postentries inwrites, andupdatesfrom the import, which nothing else uses (lint fails:'updates' is defined but never used).mcp-admin/mcp-admin.service.ts: theBLOG:line inSERVER_INSTRUCTIONSand "blog deletes are permanent" in its last paragraph.
backend/src/common/route-auth.spec.ts: remove theBlogControllerandAdminBlogControllerentries fromPOLICY. Left in, "states a policy for every registered controller, and only those" fails and names them. Inroute-auth.http.spec.ts, remove['GET', '/admin/blog/posts']fromADMIN_ROUTESand['GET', '/blog/posts']fromPUBLIC_ROUTES.backend/src/common/body-parsers.ts: remove theblogWriteJsonparser and the comment above it. It raises the body limit to 640kb for the two blog write routes; nothing else needs it.backend/src/db/types.ts: removeBlogPostRow.backend/src/scripts/seed.ts: remove thecalculateReadingTimeimport,slugify, the "Blog Posts" section (postsDataandbuildBlogPosts), the "Seeding blog posts" block inseed()and theBlog Posts:summary line.- The table: drop
blog_posts. The comment in the "keeps the row" test ofusers.service.integration.spec.tsnamesblog_posts.published_by; drop that clause. - Regenerate
openapi.jsonand the frontend types.
Frontend
Delete
app/blog/,app/backoffice/blog/,lib/blog.ts,lib/blog.test.ts,lib/markdown.tsandlib/markdown.test.ts. Only blog posts were markdown, so uninstall what rendered it, or knip fails onUnused dependencies:npm uninstall marked isomorphic-dompurifyLinks and types. In
lib/site.ts, remove the/blogentry fromlinks. Inlib/api.ts, remove theBlogPostandAdminBlogPosttypes.app/sitemap.tslists the posts. With none left it is just the home page:import { MetadataRoute } from 'next'; import { site } from '@/lib/site'; export default function sitemap(): MetadataRoute.Sitemap { return [ { url: site.url, lastModified: new Date(), changeFrequency: 'daily', priority: 1, }, ]; }app/llms.txt/route.ts: remove the Blog line, so the list ends- Home${pricingLine}.proxy.ts: removeBLOG_SLUG_PATTERN,blogPostExistsand theblogSlugMatchcheck inproxy(), and change the header comment from four jobs to three (the admin gate, the 404 for dormant/pricing, the CSP nonce). Inproxy.test.ts, removedescribe('/blog/<slug>').The backoffice. In
app/backoffice/Shell.tsx, remove the Blog entry fromNAV. Inapp/backoffice/page.tsx, remove the posts fetch fromPromise.all(andAdminBlogPostfrom the import, and'/admin/blog/posts'from thepathtype ofrecent), the "New post" button, the "Blog posts" KPI cell and the "Latest posts"RailList.The messages. In
messages/en.json(and any translation), deleteBlog,Backoffice.blogandBackoffice.postForm, plusNav.blog,Backoffice.nav.blog,Backoffice.nav.newPost,Backoffice.nav.editPost, andBackoffice.overview.posts,latestPosts,postsFailed,noPostsandpublishedOn. Nothing fails if one stays; it is dead text a translator would still be asked for.What knip then finds unused.
TextareaandCheckboxincomponents/ui/Input.tsxandFormActionsincomponents/ui/FormSection.tsx: the post form was their only user. They are building blocks you will likely want for your own forms, so keep them by tagging each with@publicin its doc comment, which knip skips:/** @public */ export function Textarea({ className, ...props }: ComponentProps<'textarea'>) {Or delete them and their names in
frontend/CLAUDE.md"Layout".
Comments that name the blog.
app/layout.tsxsays the data cache is whatlib/blog.tsrelies on; make thatlib/billing.ts, which still does. The top ofapp/styles/_prose.scsssays blog bodies andlib/markdown.ts; it styles the legal pages now. Innext.config.ts, theimages.unsplash.comentry inremotePatternswas only for the sample posts; remove it (an emptyremotePatterns: []is fine), andhttps://images.unsplash.comfromimg-srcin the CSP inproxy.ts. Inpackage.json,//enginesgivesisomorphic-dompurifyas a reason for the Node 24 floor; drop it from that sentence, leaving jsdom. Four comments still name the blog in passing:app/backoffice/users/actions.ts("the blog list's publish toggle"),app/styles/_type.scss("Blog, Sign in, 404"),lib/billing.test.ts("Same contract as lib/blog.ts") andlib/fetch-timeout.test.ts("the blog and billing reads"); reword them. Inlib/fetch-timeout.test.ts, the/blog/postsURLs are only strings for a mocked fetch and can stay.CI. The "No EACCES under real traffic" step in
.github/workflows/ci.ymlcurlshttp://localhost:3000/blog, which is now a 404 and fails the step. Curlhttp://localhost:3000/instead, and say "the landing page" for "a cached fetch on the blog" in the comment above it: the landing page reads billing status through the same data cache.
Docs that describe the blog
git grep -il blog -- docs README.md backend/CLAUDE.md frontend/CLAUDE.md
lists them. Rewrite the ones that describe the blog as a feature, and the
examples that point at deleted code.
Remove the admin MCP server
The server is backend/src/mcp-admin/ on top of backend/src/mcp-shared/,
which holds the stateless transport, the rate limiter and the OAuth 2.1
pieces (discovery, Dynamic Client Registration, PKCE, the approve endpoint
and the signed tokens). The admin server is their only user, so both go. If
you plan a server of your own, keep mcp-shared/ instead; knip then
reports its files as unused until your server imports them. The frontend
consent page, the backoffice "MCP connections" screen and the
mcp_admin_oauth_grants and mcp_admin_oauth_clients tables go with it.
Backend
Delete
backend/src/mcp-admin/,backend/src/mcp-shared/andbackend/src/common/escape-html.ts, which only the OAuth error page used (knip:Unused files). Then:npm uninstall @modelcontextprotocol/sdkbackend/src/app.module.ts: remove theMcpAdminModuleimport and its entry.backend/src/common/route-auth.spec.ts: remove fivePOLICYentries:McpAdminGrantsControllerand the four the OAuth factory built,McpAdminServiceDiscoveryController,McpAdminServiceOAuthController,McpAdminServiceConsentControllerandMcpAdminServiceTransportController, with the comment above them. Inroute-auth.http.spec.ts, remove the two/admin/mcp/grantspaths fromADMIN_ROUTES,/admin/mcp/oauth/approvefromSESSION_ROUTES, and the "refuses MCP consent to a signed-in non-admin" test.backend/src/common/validate-secrets.ts: drop "and keys the admin MCP server's OAuth tokens" from the comment aboveREQUIRED_SECRETS.backend/src/common/client-ip.spec.ts: delete thedescribe('a rate-limited route behind clientIpMiddleware')block and the four imports only it used (Server,AddressInfo,registerRateLimited,resetRateLimits). It tested the header through the MCP rate limiter; the middleware's own tests above it stay.backend/src/common/body-parsers.ts: remove theexpress.urlencodedparser and its comment; only the OAuth/tokenform posted urlencoded bodies. Drop "on the MCP endpoint" from the JSON parser's comment, and insrc/main.tsmake the comment aboveNestFactory.createsay it installs the raw parser the Stripe webhook needs.backend/src/db/types.ts: removeMcpAdminOAuthGrantRowandMcpAdminOAuthClientRow. Inbackend/src/common/frontend-url.ts, drop "and the admin MCP consent page" from the comment.Leave
MCP_ADMIN_USER_IDinbackend/src/users/users.service.ts. Migration 003 seeded that system user and the row stays in your database;auth-adapter.service.tsuses the id to keep anyone from signing in as it. Reword its comment to "A system user seeded by migration 003", since no tools write as it any more.Stale specs. If you kept the blog, delete "keeps the 32kb cap on the admin MCP endpoint" in
src/blog/blog-body-limit.http.spec.ts.The tables: drop
mcp_admin_oauth_clientsandmcp_admin_oauth_grants, in a migration with the next number on disk. Take both out ofDELETION_POLICYinbackend/src/users/deletion-policy.ts.Env vars.
BACKEND_URLwas read only by the MCP OAuth discovery documents, andADMIN_MCP_OAUTH_REDIRECT_ORIGINSandMCP_RATE_LIMIT_PER_MINonly by the server. Remove:backend/.env.example: theBACKEND_URLblock, the commentedADMIN_MCP_OAUTH_REDIRECT_ORIGINSwith its comment, the sentence on MCP tokens underINTERNAL_API_TOKEN, and theMCP_RATE_LIMIT_PER_MINblock.deploy/.env.prod.example: the admin MCP lines underINTERNAL_API_TOKEN, the commentedADMIN_MCP_OAUTH_REDIRECT_ORIGINS, and# MCP_RATE_LIMIT_PER_MIN=1200.deploy/docker-compose.prod.yml:BACKEND_URL,MCP_RATE_LIMIT_PER_MINandADMIN_MCP_OAUTH_REDIRECT_ORIGINSunderbackend.deploy/Caddyfile: the header comment's reasons for the api subdomain name/admin/mcp/oauth, the discovery documents andBACKEND_URL;/openapi.jsonand the API paths are reason enough.- Your own
backend/.envand the deployed environment.
Regenerate
openapi.jsonand the frontend types.
Frontend
- Delete
app/mcp/andapp/backoffice/connections/, remove the connections entry fromNAVinapp/backoffice/Shell.tsx, and theMcpAuthorizeandBackoffice.connectionsmessages frommessages/en.json. app/llms.txt/route.ts: remove the "For agents operating this app" section.frontend/.env.example: theNEXT_PUBLIC_API_URLcomment gives "the MCP install command" as its example; the API link in/llms.txtis one that still exists.
Docs that describe the server
Delete docs/Admin MCP Server.md, the "MCP servers" section of
backend/CLAUDE.md, and the "Add an MCP tool" row in AGENTS.md. Then
git grep -il mcp -- docs README.md AGENTS.md backend/CLAUDE.md .claude
lists the rest.
Remove workspace API keys
The keys are backend/src/api-keys/, the api_keys table and the API
keys section of Settings. SessionGuard's refusal of key-shaped tokens
(src/auth/api-key-shape.ts) is harmless without them and can stay.
Backend
- Delete
backend/src/api-keys/, and removeApiKeysModulefromapp.module.ts. - Items go back to sessions. In
items.controller.ts, replace@UseGuards(WorkspaceMemberOrApiKeyGuard)with@UseGuards(SessionGuard, WorkspaceMemberGuard)and dropviaApiKey: m.viaApiKey === true; initems.module.ts, importAuthModulein place ofApiKeysModule; initems.controller.spec.ts, overrideSessionGuardandWorkspaceMemberGuard.viaApiKeycan go fromCreateItemArgs,WorkspaceMembershipandlockWorkspaceRole, with thefalseevery other caller passes. backend/src/common/route-auth.spec.ts: remove theApiKeysControllerentry,MEMBER_OR_API_KEYand the test that checks it, and makeItemsControllerclassGuards: MEMBER.backend/src/db/types.ts: removeApiKeyRowandApiKeyWithCreatorRow.- The table: drop
api_keysin a migration with the next number on disk. - Regenerate
openapi.jsonand the frontend types.
Frontend
- Delete
app/settings/ApiKeys.tsxandCreateApiKeyForm.tsx, removegetApiKeysand the<ApiKeys>section fromapp/settings/page.tsx, the two key actions fromworkspace-actions.ts, and theApiKeytype and.keyRevealstyle.
Docs that describe the keys
The "API keys" section and the two roles rows in docs/Teams and Workspaces.md, the api-keys rows in docs/Architecture/4 REST Surface.md, and the API keys row in AGENTS.md. git grep -il "api key" -- docs AGENTS.md backend/CLAUDE.md .claude lists the rest.
Billing: leave it switched off
Billing is not removable in a few steps. It is wired into three features you keep:
- The free-plan cap.
ItemsService.createreads the workspace owner'sgetEntitlementand caps an owner withoutpaidAccessat 100 items across their workspaces. With billing off, that is every owner. - Account deletion. The deletion worker's second half is
deleteCustomerData, which deletes the Stripe customer when billing is on, anddeletion-policy.tshas an entry for each of the fivebilling_*tables. - Housekeeping.
session-cleanup.worker.tssweepsbilling_webhook_eventsandbilling_payment_hook_claims. - One-time purchases.
src/purchases/is bound to billing as its payment hook inapp.module.ts, andgetEntitlementasks it for lifetime grants.
To leave it off, unset every STRIPE_* variable and do nothing else; the
first section lists what then stays hidden. If the item cap is the only
part that bothers you, lift it in ItemsService.create, which is one
if.
Removing it fully touches the item cap, account deletion, both workers,
the guard specs, five tables plus purchase_grants, the purchases
module, and most of frontend/app/; it was not run
end to end for this page. Keep FRONTEND_URL if you do: workspace invite
links, Stripe return URLs and the admin MCP consent page are built from
it.
What the test run found
- Blog, following its section on a fresh copy: the first verify
failed at backend lint on the
updatesimport inmcp-admin-tools.service.spec.ts, now a step above. Frontend knip reportedTextarea,CheckboxandFormActions. With the originalpackage.jsonput back, it also reportsmarkedandisomorphic-dompurifyunderUnused dependencies. With every step done, verify: all green (integration specs ran). - Admin MCP server: backend knip reported
src/common/escape-html.tsoncemcp-shared/was gone, and@modelcontextprotocol/sdkunderUnused dependenciesuntil it was uninstalled. With both done, verify: all green (integration specs ran) on the first full run. - Billing was not removed. The template's own verify runs with every
STRIPE_*unset, which is the switched-off state, and is green. - The early return for an upstream migration that alters a dropped table was not exercised: no such upstream migration exists yet.
Related
- Updating from upstream - merging after a removal
- What Ghost Deletion Erases - the deletion policy
- Testing - the guard specs a removal trips
- What refuses boot