DocsPricingBlogChangelog
Examples use: Lease

Every code example, in your word.

Sign inStart building

Defaults and overrides

Open

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

QuestionDefaultTo change it
What does it look like?A neutral theme in light and dark, on every screen, page and email, and on Stripe CheckoutSet 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 oneSet 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 teamReplace 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 billingLimit 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 emailYour own field component
What does a list screen show?At most 7 columns, and one button per VerbYour own view
What does a record page show?Every field, a button per Verb and the record's full historyYour own view
What does a Verb do?Writes its input onto the record and moves it to the Verb's to stateYour own function
What does the home page say?Your product's name, what it keeps and its plans, written from the definitionYour own page
Where does the price live?In the Offer, as one amount and currency that the pricing page, Checkout and the API all readEdit 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.

QuestionAnswer
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.

theme.json
{
  "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.

theme.css
.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.

copy.json
{
  "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.

fields/endsOn.tsx
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.

views/Lease.tsx
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.

pages/index.tsx
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.

Noun/Lease.mdx
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.

Contents