Architecture
How the code is organised, and why swapping a provider does not mean rewriting your product.
ApisKit uses hexagonal architecture, also called ports and adapters. In one sentence: the heart of your business does not know that Firebase exists, or Stripe, or Next.js. It knows a contract exists, and that somebody fulfils it.
That is not academic purism. It is what makes changing provider a matter of writing one new file instead of rewriting the product.
The layers
src/
core/ the domain, with nothing from outside
entities/ business objects (User, SupportTicket, Trial,
CreditPurchase, Payment, DocPage)
ports/ contracts the outside world must fulfil
(AuthPort, UserPort, PaymentPort, StoragePort, DocsPort)
adapters/ who actually fulfils those contracts
database/ Firebase and Firestore (plus an in-memory one, for tests)
payment/ Stripe
app/ Next.js routes, API and pages: where everything is wired
presentation/ React components
lib/ cross-cutting helpers (session, email, errors, SEO,
rate limiting, documentation rendering)
config/ product.ts (identity), languages, theme, firebase, pricing
locales/ es/ and en/ translations
The dependency rule
Dependencies point inward, never outward:
core/imports nothing from outside. Not one library. It defines interfaces.adapters/knowcoreand the external SDKs, and connect them. Providers live here and nowhere else.app/andpresentation/usecoreand the adapters, never the reverse.
If one day you search for "stripe" inside src/core/, it must not appear. The
day it does, the advantage is gone.
A port can also be split by permission
The usual reason to have a port is swapping provider. There is another, less used, that prevents silent failures: splitting it by what each caller is allowed to do.
DocsPort is two interfaces, not one:
DocsReadPort, which only knows how to read published pages.DocsAdminPort, which can do everything, drafts included.
The public page is handed the read port. So it is not that it should not ask for a draft: it has no method to ask with. The leak is not prevented by discipline or by code review, it is prevented because it cannot be typed.
Use the same trick anywhere a leak would be silent.
What this buys you
Changing database or payment gateway means writing a new adapter against the same port and changing where it is wired. Your business rules stay untouched.
It is also what lets whole pieces, such as the support system, be lifted out and carried into another application: they talk to the rest only through a small, written contract.
Identity and configuration
src/config/product.ts is the single source of truth for identity: name,
company, addresses, support email. It is validated with Zod when the module
loads, so a misconfigured product fails at startup instead of deploying
broken.
Search visibility, emails, structured data, header, footer and translated copy all read from it. That is why rebranding is one command: there is only one place to change.
The two languages
src/config/i18n provides the provider and the t() function. Translations
live in src/locales/{es,en}/<namespace>.json. Identity tokens ({brand},
{company}, {supportEmail}, {baseUrl}) are injected into every string, so
nobody writes the brand name by hand.
npm run verify:i18n checks that English and Spanish are symmetric and that
every t() points at a key that exists.
Where your product goes
Your domain is new entities and ports in core/, with their adapters in
adapters/, exposed through routes in app/ and components in presentation/.
If adapting your product forces you to change code that was already in core/,
treat it as a template defect and fix it there, not as a patch for your case.
That is the difference between a foundation that holds and one that rots.