nextjs on GitHub

nextjs/saas-starter

Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.

24 files reviewed·July 25, 2026

View on GitHub

Elevated

Design risk in this codebase.

12issues
Critical, Serious & Moderate · top 6 shown below

Top fix

Fix contrast on Save Changes and Invite Member buttons

See the fix

Verdict

Buttons you can't read and a delete flow with no brakes define this build. The biggest risk is contrast and confirmation failures on the exact actions, saving and deleting, where trust matters most.

Files Rams reviewed

app/(dashboard)/dashboard/activity/page.tsx

app/(dashboard)/dashboard/general/page.tsx

app/(dashboard)/dashboard/layout.tsx

app/(dashboard)/dashboard/page.tsx

app/(dashboard)/dashboard/security/page.tsx

app/(dashboard)/layout.tsx

app/(dashboard)/page.tsx

app/(dashboard)/pricing/page.tsx

app/layout.tsx

app/(login)/sign-in/page.tsx

app/(login)/sign-up/page.tsx

app/(dashboard)/terminal.tsx

app/(login)/login.tsx

app/globals.css

app/not-found.tsx

components/ui/avatar.tsx

components/ui/button.tsx

components/ui/card.tsx

components/ui/dropdown-menu.tsx

components/ui/radio-group.tsx

app/(dashboard)/dashboard/activity/loading.tsx

app/(dashboard)/pricing/submit-button.tsx

components/ui/input.tsx

components/ui/label.tsx

94/100

Color

2 critical
ColorCritical

app/(dashboard)/dashboard/general/page.tsx:104

"Save Changes" button text fails contrast against its own fill

The submit button reading "Save Changes" uses white text on bg-orange-500 (~1.3:1) and bg-orange-600 on hover (~1.3:1). Both sit below the 4.5:1 minimum for normal-size text.

Why it matters

Users with low vision or in bright ambient light can't read the primary action label on the account settings form, and this is the only submit control on the page.

Fix

Darken the fill or switch to a token pairing that clears 4.5:1 for white text.

className="bg-orange-500 hover:bg-orange-600 text-white"
className="bg-orange-700 hover:bg-orange-800 text-white"
ColorCritical

app/(dashboard)/dashboard/page.tsx:227

"Invite Member" button text is unreadable at normal size

The invite form's submit button uses the same white-on-orange-500 pairing (~2.8:1 contrast), the only path a team owner has to send an invitation.

Why it matters

A primary action button that's hard to read undermines confidence the invite actually went through, and fails WCAG AA for anyone with reduced contrast sensitivity.

Fix

Darken the fill or switch to a token pairing that clears 4.5:1 for white text.

className="bg-orange-500 hover:bg-orange-600 text-white"
className="bg-orange-700 hover:bg-orange-800 text-white"
95/100

Accessibility

1 critical1 serious
AccessibilityCritical

app/layout.tsx:12

Viewport export blocks pinch-to-zoom for every visitor

The root layout's viewport export sets maximumScale: 1 with no accompanying input-zoom bug to justify it. On browsers that honor this meta value, users cannot pinch-zoom anywhere in the app, including dashboard forms and settings pages.

Why it matters

Low-vision users rely on pinch-zoom to read small text and hit tap targets accurately. Locking zoom removes a core accessibility mechanism sitewide, not just on one page.

Fix

Remove maximumScale from the viewport config so native pinch-zoom stays available.

export const viewport: Viewport = {
  maximumScale: 1
};
export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1
};
AccessibilitySerious

app/(dashboard)/dashboard/layout.tsx:32

Mobile menu toggle never announces open or closed state

The "Toggle sidebar" button drives isSidebarOpen via onClick but exposes no aria-expanded attribute, so its state never reaches the accessibility tree.

Why it matters

Screen reader users get no announcement of whether the drawer is open or closed after activating the control, forcing them to re-explore the DOM to confirm the result of their own action.

Fix

Bind aria-expanded to the same boolean that controls the drawer's visibility.

<Button
  className="-mr-3"
  variant="ghost"
  onClick={() => setIsSidebarOpen(!isSidebarOpen)}
>
  <Menu className="h-6 w-6" />
  <span className="sr-only">Toggle sidebar</span>
</Button>
<Button
  className="-mr-3"
  variant="ghost"
  aria-expanded={isSidebarOpen}
  onClick={() => setIsSidebarOpen(!isSidebarOpen)}
>
  <Menu className="h-6 w-6" />
  <span className="sr-only">Toggle sidebar</span>
</Button>

Get this score on every PR.

Rams reviews each pull request on your repo and posts inline one-click fixes — about a minute per review.

Install Rams free
95/100

UX

1 critical1 serious
UXCritical

app/(dashboard)/dashboard/security/page.tsx:144

"Delete Account" submits instantly with no confirmation step

The security page's "Delete Account" button is type="submit" inside a form that fires on click or on Enter from the password field above it. There is no intermediate confirm dialog before the account is destroyed.

Why it matters

A misclick, an accidental Enter keypress while typing the password, or a double-tap permanently deletes the account with no recovery path, the highest-stakes action on the page treated like any other form submit.

Fix

Insert a confirmation dialog between the button click and the destructive form action.

<Button
  type="submit"
  variant="destructive"
  className="bg-red-600 hover:bg-red-700"
  disabled={isDeletePending}
>
<Button
  type="button"
  variant="destructive"
  className="bg-red-600 hover:bg-red-700"
  disabled={isDeletePending}
  onClick={() => setShowConfirmDialog(true)}
>
UXSerious

app/(dashboard)/dashboard/activity/page.tsx:71

Activity log has no fallback if the data fetch fails

ActivityPage awaits getActivityLogs() directly in the server component body with no try/catch and no loading or error branch, only a check on logs.length for the empty state.

Why it matters

A slow or failing database call surfaces as a blank page or an unstyled framework error screen instead of a recognizable error state, leaving users unsure whether their activity history is gone or just loading.

Fix

Wrap the fetch in error handling and render a distinct error state separate from the empty-list state.

export default async function ActivityPage() {
  const logs = await getActivityLogs();
export default async function ActivityPage() {
  let logs;
  try {
    logs = await getActivityLogs();
  } catch {
    return <ActivityErrorState />;
  }

Typography

No issues found

Spacing

No issues found

Components

No issues found

Motion

No issues found

Craft

No issues found

Working well

  • InviteTeamMember correctly disables the form and shows a CardFooter explanation when !isOwner, giving non-owners a clear reason instead of a silently broken form.
  • The password fields correctly use autoComplete="current-password" and "new-password", which lets password managers fill and suggest strong new passwords.
  • Using SWRConfig fallback with server-fetched user/team data is a clean pattern for avoiding client-side loading flicker on first paint.
  • The sidebar slide uses transform (translate-x) rather than animating left/width, which keeps the drawer transition GPU-accelerated.

Scored July 25, 2026 with Rams Engine v0.0.3 · Engine changelog
First scored July 9, 2026: 59/100. This rescore on v0.0.3: 59/100.

This page is an automated design review of nextjs/saas-starter’s UI code: 24 files read against 309 versioned rules covering accessibility, color, typography, spacing, components, UX, motion, and craft. The score is out of 100; confirmed criticals cap it — one at 59, two at 49, three or more at 39.

More design scores

Score your own repo.

Free on public repos, no account. The same engine that scored this page reads your UI code and mints a score page like this one.

Public repos only. The full engine reviews the UI code and mints a public score page — we email you the link too. Already-scored repos open instantly.

Or get a design review on every pull requestInstall Rams