Replace the Reference Business
Start with one small change: turn the private bookmark workspace into a reading list. Titles and links still fit the existing Bookmark model, so you can change the product language and login layout while keeping the proven resource service. When your product needs a different entity, use the replacement map below to change its model, persistence and consumers together.
Complete installation first. Use a disposable local database for the exercise; no payment, analytics or email account is needed. The example checks a successful save, recovery of the same intent, another account’s denied read and an anonymous denied write.
1. Choose the first product boundary
Write down the resource, its owner, the operation and the result the customer should observe. For this exercise:
| Decision | Reading-list example | When replacing the entity |
|---|---|---|
| Resource | A title and an HTTP(S) link | Define your own fields and invariants before adding columns |
| Ownership | The current person’s collection | Choose personal ownership or an explicit organization scope; creating an organization never transfers personal records |
| First operation | Save a link and open it again | Give uncertain writes a stable identity and a way to read their outcome |
| Commercial policy | Free save/read/edit/delete | Add a server-side entitlement only to the operation that requires payment |
| First screen | Keep /workspace, call it “Reading list” | Migrate links and return destinations together if the URL changes |
For a reading list, change the customer-facing headings and labels in src/app/(site)/workspace/page.tsx and the route-private src/app/(site)/_bookmarks/ views. Keep the /workspace routes and Bookmark service names for this first exercise: their domain meaning still fits. Do not rename persistent table identities or historical purchase keys just to change the product’s name.
2. Set the public identity
Edit publicProductConfig in src/config/product.ts: set your brand name, description, local logo/favicon and your own support/repository links. The root metadata, site header and documentation shell already consume this declaration. Keep absent optional links null; no secret belongs here.
For the free exercise, use checkout: "off", feedbackAttachments: false, analyticsClient: false and analyticsServer: false. Keep docs available while you customize. brand.locale sets document language; it does not translate the UI.
3. Keep authority inside the resource service
The real server entry is ~/server/bookmarks. A page or Server Action supplies a scope and customer input. The service resolves the current account; the browser never supplies an actor, role, store or personal owner ID.
import { createBookmark, findBookmarkCreation } from "~/server/bookmarks";
import type { BookmarkCreateCommand } from "~/server/bookmarks";
const command = {
createIntentId: crypto.randomUUID(),
draft: { title: "Read about module boundaries", url: "https://example.com/reading" },
} satisfies BookmarkCreateCommand;
const result = await createBookmark({ kind: "personal" }, command);
// If the response is uncertain, keep command unchanged and observe its intent.
const observation = await findBookmarkCreation({ kind: "personal" }, command.createIntentId);Create the intent once for a new user action, outside retries. The existing _bookmarks/actions.ts and bookmark-form.tsx already preserve the command and map the result into the application. Reuse their flow while changing the presentation. These server calls must not be imported into a client component; the existing Actions relay safe result types and server operations.
For a shared collection, supply { kind: "organization", organizationId } instead. A route parameter selects a scope; it does not authorize it. The service checks current membership, and the shipped SQL adapter repeats organization authorization in the write statement. Organization duplication is currently unsupported; personal billing does not turn into team billing by changing the scope.
Run the typed example
The maintained example is src/app/docs/guides/replace-reference-business/_examples/reading-list.ts. It consumes the real BookmarkService contract, saves one link, observes the original intent and attempts to read the saved ID through another account’s service. Its adjacent test composes the real service and SQLite adapter over an in-memory database using the repository migrations.
node --import tsx --test src/app/docs/guides/replace-reference-business/_examples/reading-list.test.tsThe test owns and closes its database. Synthetic account identities are supplied only by its test composer; they are not an authentication recipe for production. The application’s default runtime continues to obtain identity from Auth.
| Exercise observation | Required result |
|---|---|
| The owner saves a valid link | ok: true, a saved resource with stable ID and version |
| The owner observes the original create intent | The same resource; one database row |
| Another signed-in account reads that ID | not_found, without disclosing the resource |
| An anonymous caller tries to save | unauthenticated, before acquiring storage |
This verifies the local personal-resource seam. It does not establish a new entity’s business rules, organization behavior on every adapter, or an external-provider guarantee.
4. Preserve result and recovery behavior
Keep the service’s result distinctions visible when replacing forms or screens:
| Result | What the interface should do |
|---|---|
validation | Keep the draft and show field errors |
unauthenticated, account_unavailable, access_denied | Show the appropriate sign-in or refusal state; hiding a button is not authorization |
not_found | Show absence without revealing whether another account owns the ID |
version_conflict | Keep the draft; let the customer inspect and explicitly adopt the current version |
indeterminate or lost response | Keep the exact scope/intent/version/input; offer a read to recover the outcome, not a fresh automatic write |
| Confirmed saved or deleted | Show the returned fact; a failed page refresh must not turn a confirmed write into failure |
findBookmarkCreation returning absent is an observation, not proof that a previous request was canceled. Only retry the original command after the customer chooses to continue. Read failures must not become an empty collection. The existing form, confirmation helper and deletion receipt already implement these distinctions.
5. Replace the model when your business needs one
If your product is tasks, documents or another entity rather than saved links, replace these cohesive parts. Keep the existing contracts as a reference, not a requirement to give every domain Bookmark’s fields or methods.
| Responsibility | Current customization point | Replacement work |
|---|---|---|
| Public operations and safe results | src/server/bookmarks/contracts.ts | Define your entity’s commands, queries, rejected actions and recovery results |
| Owned facts and invariants | bookmarks/domain.ts, bookmarks/service/ | Keep identity/version/lifecycle rules with their owner; validate untrusted input on the server |
| Required capabilities and implementation | bookmarks/ports.ts, adapters/sqlite.ts, runtime.ts | Inject fresh identity, storage and any narrowly required entitlement; composition selects implementations |
| Stored records and evolution | src/server/db/tables/bookmarks.ts, schema exports and generated migrations | Design actual ownership, constraints and old-data migration; do not apply a new entity’s schema over existing customer records by renaming fields |
| Pages, Actions and views | src/app/(site)/workspace/, shared _bookmarks/, organization resource routes | Migrate real consumers and their result types; remove unused old internal paths once all consumers move |
| First-use facts | src/application/product/first-use-runtime.ts and business.ts | Adapt domain reads to safe resource observations and supply create/continue presentation. A zero count is not an account-registration state |
| Navigation and return intent | src/application/product/navigation/ and AUTH_LOGIN_REDIRECT | Point customers at the new usable entry, preserving safe explicit callback URLs |
| Paid action and account export | Entitlement binding, pricing and /me/bookmarks/export | Wire the new resource operation and data projection; a new catalog label does not implement the purchased capability |
The application product layer owns site/account/admin navigation, selected business destinations and first-use copy. Header, account and first-use views consume those projections. Set publicProductConfig.business to select a shipped business, or add its explicit application adapter. Set AUTH_LOGIN_REDIRECT when changing the default post-login destination. Preserve src/lib/auth-return.ts validation: external, secret-bearing and authentication-loop destinations stay rejected. Its final safe fallback is /me.
After changing the domain, test two accounts and, if selected, two organizations. Cover a successful operation, a foreign resource ID, revoked membership, a stale version and a lost response. Leave the free resource path independent of payment-provider configuration.
6. Map a paid operation deliberately
Skip this step for the free reading list. For a paid product, use /admin/catalog to publish a Product and an immutable Offer revision. The canonical contract is src/server/payments/catalog/contracts.ts; the Payments entry exports readCatalogManagement, publishCatalogProduct, publishCatalogOffer and withdrawCatalogOffer. Every read or mutation checks current administrator access.
An Offer needs an existing non-deleted plan ID/slug reference, its own Stripe price reference, product/offer keys, a revision and the allowed capabilities. Current pricing supports a positive integer amount in supported cents currencies and a month/year interval. The admin form does not create Stripe prices or verify a remote price’s terms. Publishing a legacy plan alone does not publish an Offer; withdrawing an Offer prevents new purchases without changing bought terms.
Then bind the purchased capability to an actual server operation. src/application/product/access.ts shows reference.bookmarks → bookmarks.copy; the personal JSON export has its own rule and consumer. createPaidEntitlementChecker in ~/server/entitlements reads trusted local commercial facts. It does not own resources, call a provider or accept a cheaper policy chosen by the browser.
When replacing the reference product, update the rule and the actual consumer together with src/app/(site)/pricing/page.tsx, the account summary and src/app/(site)/me/bookmarks/export/route.ts. Keep historical product/offer identities and bought capabilities meaningful. A checkout return URL never grants access, and recovery of a completed resource intent must not charge or require a new purchase.
The billing entry selects a trusted customer association for a product, but Stripe’s Billing Portal manages that provider customer. It can show other subscriptions or products linked to the same customer; keep that scope clear in your interface. Available cancellation, payment-method and invoice actions depend on your actual Stripe Portal configuration and need separate provider verification.
7. Change the login layout without changing login policy
The existing LoginForm creates its model with useLoginOperation. Its view receives LoginViewProps: transient state, safe provider descriptions and commands to edit, submit, begin OAuth, stop waiting and recover.
A maintained compact alternative now lives in src/app/(site)/login/compact-login-view.tsx. To try it, change one import in login-form.tsx:
-import { LoginView } from "./login-view";
+import { CompactLoginView as LoginView } from "./compact-login-view";Leave the hook, trusted server actions and model wiring in place. Both renderers consume the same props. The compact view keeps field labels, disabled/pending behavior, errors, OAuth actions and unknown-result recovery. “Stop waiting” does not cancel server authentication; “Check sign-in status” returns through the trusted login entry. Passwords remain transient and are never copied into URLs or storage.
The existing login-view.fixture.tsx mounts both renderers with the same controlled operation. Its focused check exercises keyboard submission, rejection, pending cancellation, late replies and recovery at a narrow viewport:
node --import tsx --test 'src/app/(site)/login/login-view.browser.test.ts'It requires the installed Playwright Chromium browser, bundles in memory and starts no Next server. It verifies the operation/view seam; the real login route still needs its separate Auth.js callback and cookie checks.
8. Select optional modules with explicit boundaries
| Selection or removal | Current effect | If you want more |
|---|---|---|
capabilities.docs / blog false | Hides navigation; direct content routes return 404. Disabled Blog also rejects editorial Actions, omits its refresh job and never initializes its default sources | Rebuild/restart after changing selection. Bundled Nextra/Velite dependencies and stored content remain; package or data removal is a separate change |
analyticsClient / analyticsServer false | Disables the corresponding analytics initialization/capture even with residual keys | Enabling still requires its own public/client or server PostHog key/host. Configured is not a delivery guarantee |
| Analytics enabled | Browser automatic pageviews include path only; query/hash/referrer/campaign and replay/autocapture are excluded | Define product events separately; current server consumers are Auth.js registration/login |
| Checkout off | Selling is disabled by its resolver | Keep free resources usable; remove pricing/account purchase entry points only as part of a deliberate product change |
| Reference tools, organization links or Bookmark | No general uninstall switch | Remove their route/menu/first-use consumers deliberately; preserve any existing data and ownership obligations |
Consult configuration for the runtime lanes and environment inputs. A navigation choice is never a resource authorization policy.
Blog stays usable with bundled MDX and database publication when no CMS is configured. If any Notion or Sanity inputs are present, complete that source’s required inputs or remove them. The System page reports missing input names separately from the Blog module; a configured source still needs runtime verification. Disabling Blog takes priority over any residual CMS credentials. Enabling editorial routes never bypasses current administrator authorization.
Finish the adoption exercise
Confirm your brand appears in the site and docs, the reading-list screen saves and reopens a link, a second account cannot read it, first-use/menu/login return paths reach the intended screen, and both login renderers preserve recovery. If you replace the entity, rerun these observations through that entity’s real service and persistence adapter.
Run the project’s lint/type checks and relevant page/build checks after your changes. Use a disposable database for browser tests; the standard E2E setup prepares and cleans test state. Real payment, email and deployment verification belongs to the capabilities you actually enable. The example commands above do not prove those external effects.
A different business lifecycle
Set publicProductConfig.business to "decision-log" to select the executable Decision Log example. Its private proposal and rationale become a final approved decision; it does not reuse Bookmark records. The real /decisions route, first-use draft/approved counts, site and account destinations use src/application/product/. Publish a catalog product decisions.approval with an offer capability decisions.approve using the existing catalog workflow. The application binds that capability to approval and the customer explanation. Free proposals do not require payment configuration.
The additive 0038_decision_log migration preserves existing data. Same-owner intent recovery and version checks belong to Decision Log; Auth, Payments and Entitlements algorithms are reused. Existing bookmark, API credential, billing, usage and organization maintenance remain reachable after selection changes. Selection changes navigation, never authorization. This example is not an installation-pruning or plugin system.