Skip to Content
GuidesReplace the Reference Business

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:

DecisionReading-list exampleWhen replacing the entity
ResourceA title and an HTTP(S) linkDefine your own fields and invariants before adding columns
OwnershipThe current person’s collectionChoose personal ownership or an explicit organization scope; creating an organization never transfers personal records
First operationSave a link and open it againGive uncertain writes a stable identity and a way to read their outcome
Commercial policyFree save/read/edit/deleteAdd a server-side entitlement only to the operation that requires payment
First screenKeep /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.ts

The 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 observationRequired result
The owner saves a valid linkok: true, a saved resource with stable ID and version
The owner observes the original create intentThe same resource; one database row
Another signed-in account reads that IDnot_found, without disclosing the resource
An anonymous caller tries to saveunauthenticated, 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:

ResultWhat the interface should do
validationKeep the draft and show field errors
unauthenticated, account_unavailable, access_deniedShow the appropriate sign-in or refusal state; hiding a button is not authorization
not_foundShow absence without revealing whether another account owns the ID
version_conflictKeep the draft; let the customer inspect and explicitly adopt the current version
indeterminate or lost responseKeep the exact scope/intent/version/input; offer a read to recover the outcome, not a fresh automatic write
Confirmed saved or deletedShow 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.

ResponsibilityCurrent customization pointReplacement work
Public operations and safe resultssrc/server/bookmarks/contracts.tsDefine your entity’s commands, queries, rejected actions and recovery results
Owned facts and invariantsbookmarks/domain.ts, bookmarks/service/Keep identity/version/lifecycle rules with their owner; validate untrusted input on the server
Required capabilities and implementationbookmarks/ports.ts, adapters/sqlite.ts, runtime.tsInject fresh identity, storage and any narrowly required entitlement; composition selects implementations
Stored records and evolutionsrc/server/db/tables/bookmarks.ts, schema exports and generated migrationsDesign 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 viewssrc/app/(site)/workspace/, shared _bookmarks/, organization resource routesMigrate real consumers and their result types; remove unused old internal paths once all consumers move
First-use factssrc/application/product/first-use-runtime.ts and business.tsAdapt domain reads to safe resource observations and supply create/continue presentation. A zero count is not an account-registration state
Navigation and return intentsrc/application/product/navigation/ and AUTH_LOGIN_REDIRECTPoint customers at the new usable entry, preserving safe explicit callback URLs
Paid action and account exportEntitlement binding, pricing and /me/bookmarks/exportWire 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 removalCurrent effectIf you want more
capabilities.docs / blog falseHides navigation; direct content routes return 404. Disabled Blog also rejects editorial Actions, omits its refresh job and never initializes its default sourcesRebuild/restart after changing selection. Bundled Nextra/Velite dependencies and stored content remain; package or data removal is a separate change
analyticsClient / analyticsServer falseDisables the corresponding analytics initialization/capture even with residual keysEnabling still requires its own public/client or server PostHog key/host. Configured is not a delivery guarantee
Analytics enabledBrowser automatic pageviews include path only; query/hash/referrer/campaign and replay/autocapture are excludedDefine product events separately; current server consumers are Auth.js registration/login
Checkout offSelling is disabled by its resolverKeep free resources usable; remove pricing/account purchase entry points only as part of a deliberate product change
Reference tools, organization links or BookmarkNo general uninstall switchRemove 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.