Defaults and overrides
Each decision a SaaS needs, such as how sign-in works, who can see what, and what an empty list says, already has a default. Each default sits beside the way to override it, from a three-line theme to your own React component, with what you maintain afterwards.
Defaults you can change
| Question | Default | To change it |
|---|---|---|
| What does it look like? | A neutral theme in light and dark, on every screen, page and email, and on Stripe Checkout | Set a font and two colours in theme.json, or add CSS |
| What does an empty list say? | "No leases yet", with a button to add the first one | Set the words in copy.json |
| How does sign-in work? | A sign-in page on your domain, in your theme, where each customer invites their own team | Replace it with your own page. Sessions and roles stay generated |
| Who may do what? | Four roles: owner, admin, member and viewer. Viewers read, members run Verbs, admins manage the team and the owner manages billing | Limit a Verb to some roles with roles: [owner, admin] on the Verb |
| How is a field entered? | The input for its type: a date picker for a date, an input that rejects a non-address for an email | Your own field component |
| What does a list screen show? | At most 7 columns, and one button per Verb | Your own view |
| What does a record page show? | Every field, a button per Verb and the record's full history | Your own view |
| What does a Verb do? | Writes its input onto the record and moves it to the Verb's to state | Your own function |
| What does the home page say? | Your product's name, what it keeps and its plans, written from the definition | Your own page |
| Where does the price live? | In the Offer, as one amount and currency that the pricing page, Checkout and the API all read | Edit price in the Offer and push |
Decisions that stay fixed
Some decisions hold on every surface, so a retried request, a script and a customer's agent behave the same in the app, the API, the CLI and MCP. Constraints gives the reason for each one.
| Question | Answer |
|---|---|
| Who sees a new record? | Only the people its workspace grants. Every record starts private. |
| What does delete do? | It saves a new version, so nothing is lost and revert brings the record back. |
| Can a GET change anything? | No. A GET on a Verb's URL previews the change and runs nothing. |
| Does a retried Verb run twice? | No. A Verb's POST carries an Idempotency-Key, and a retry with the same key returns the first response. |
| How much does a script commit? | All its writes, or none. |
| How many records come back in a list? | At most 200 a page. |
| What are the methods called? | get, list, find, create, update and revert, plus each Verb you declare. |
Where overrides live
Each override is a file in the same folder as Leaseclock's Noun and Offer files. npx saas.dev push sends it with the rest, and every surface has it on the next request. To return to a default, delete the file and push again.
Theme
Leaseclock's teal comes from three lines. They set the font, the brand colour and the text colour for every screen, page and email, and for Stripe Checkout and the billing portal.
{
"font": "Inter",
"brand": "#0F766E",
"ink": "#0F1F1D"
}You maintain three values. The dark theme, hover states and focus rings are worked out from them, at a readable contrast.
CSS
Every generated element carries a stable class name, so a rule keeps matching through each release. Northgate Rentals manages 140 units, so Leaseclock tightens its list rows to fit more leases on a screen.
.list td { padding-block: 6px; }You maintain one rule.
Words
Every label, button, empty list and email has default words, written from the Noun and Verb names. Set any of them by key. Each key names the Noun, then the screen or Verb, then the part.
{
"Lease.list.empty": "No leases yet. Add a lease to start tracking its end date.",
"Lease.renew.button": "Renew lease"
}You maintain those strings. Every other word stays generated, and a new Noun or Verb arrives with words of its own.
A field
A field component replaces the input for one field wherever it appears: the create form, the edit form and the Renew popover. Priya Shah renews most leases for a year, so Leaseclock adds presets to the end date.
import type { FieldProps } from 'saas.dev/react'
export default function EndsOn({ value, onChange }: FieldProps<'Lease', 'endsOn'>) {
const add = (months: number) => {
const date = new Date(value)
date.setUTCMonth(date.getUTCMonth() + months)
onChange(date.toISOString().slice(0, 10))
}
return (
<div>
<input type="date" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
<button type="button" disabled={!value} onClick={() => add(6)}>+6 months</button>
<button type="button" disabled={!value} onClick={() => add(12)}>+12 months</button>
</div>
)
}The Renew popover opens on the current end date, so on Dana Whitfield's lease in Unit 4B, +12 months sets 2027-11-30. FieldProps comes from saas.dev/react, typed from your definition: npx saas.dev pull writes .saas/types.d.ts, so renaming endsOn in Lease.mdx shows up here as a type error.
You maintain one component. The label, the required mark and the check that the value is a date stay generated.
A view
A view replaces one screen of the app. views/Lease.tsx replaces a Lease's record page, and views/Lease.list.tsx replaces the Leases list. Leaseclock's record page leads with the unit and the end date.
import type { ViewProps } from 'saas.dev/react'
export default function LeasePage({ lease, history, Verbs }: ViewProps<'Lease'>) {
return (
<article>
<h1>Unit {lease.unit.label}, {lease.unit.building}</h1>
<p>{lease.renter}. Ends {lease.endsOn}.</p>
<Verbs />
<ol>
{history.map((version) => (
<li key={version.id}>{version.at}: {version.summary}</li>
))}
</ol>
</article>
)
}<Verbs /> renders the generated Renew and End buttons, with their popovers, permissions and disabled states.
You maintain one screen. Sign-in, permissions, the Renew popover and the API stay generated, and the view keeps your theme.
A page
A page replaces one page of your site, such as the home page, the pricing page or sign-in. Leaseclock's home page sets its own headline and reads the price from the Offer, so the page and the charge always agree.
import type { PageProps } from 'saas.dev/react'
export default function Home({ offers }: PageProps) {
const pro = offers.pro
return (
<main>
<h1>Know which leases end next</h1>
<p>Leaseclock shows your team every lease end date across your buildings, and renews a lease in one step.</p>
<a href="/pricing">{pro.name}, ${pro.price.amount} a month</a>
</main>
)
}You maintain one page. Its URL, the header and footer, and the sitemap stay generated. See it live at leaseclock.com.
A Verb's logic
A Verb's default writes its input onto the record. To add a rule, export a function with the Verb's name after the front matter of the Noun's file. Leaseclock rejects a renewal that moves the end date backwards.
export const renew = async (lease, { endsOn }) => {
if (endsOn <= lease.endsOn) throw new Error('endsOn must be later')
return { endsOn }
}When Priya enters an earlier date, the Renew popover shows the message, and an API caller gets a 422 with the same words. A preview checks the same rule, so a GET on the Verb's URL returns the message and changes nothing. Errors lists every code.
You maintain one function. Permissions, preview, undo, history and the Idempotency-Key still wrap it.
Choosing an override
Every default you keep improves with each SaaS.Dev release. When the generated record page gains a feature, Unit's page has it the same day, and Leaseclock's own Lease page does not, because Leaseclock maintains it. Start with the theme and words, add CSS next, and write a component only for what those cannot say. The fewer defaults you replace, the more improvements you get for free.