Rams MCP · The full engine, now in your coding agent
nextjs on GitHub

nextjs/saas-starter

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

24 files reviewed·July 9, 2026

Elevated

Accessibility needs attention.

6issues
Critical & Serious

Top fix

Add a confirmation step before Delete Account submits

See the fix

Verdict

Solid architecture keeps getting undermined by hand-rolled UI: custom orange buttons ignore the design system, and destructive actions fire with zero confirmation. The structure is there, but safety nets and accessibility plumbing were skipped throughout.

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

89/100

Accessibility

1 critical4 serious
Accessibility·app/layout.tsx:12Critical

Viewport locks pinch-zoom, blocking low-vision users everywhere

`export const viewport: Viewport = { maximumScale: 1 }` caps zoom on every page in the app.

Why it matters

iOS ignores this cap for pinch-zoom but Android Chrome and other mobile browsers honor it, so low-vision users on those browsers lose the ability to zoom the entire app, not just a form input.

Fix

Remove the zoom cap and fix any input-zoom problem at the input level with a 16px font size instead.

export const viewport: Viewport = {
  maximumScale: 1
};
export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1
};
Accessibility·app/(dashboard)/dashboard/general/page.tsx:111Serious

Save success and error text render with no live region for screen readers

`{state.error && <p className="text-red-500 text-sm">...}` and `{state.success && <p className="text-green-500 text-sm">...}` insert feedback into the DOM after form submit with no `role` or `aria-live`.

Why it matters

Sighted users see the message appear after clicking "Save Changes", but screen reader users get no announcement and can't tell whether the save succeeded or failed.

Fix

Add role="alert" (or role="status" for the success message) so assistive tech announces the outcome automatically.

{state.error && (
  <p className="text-red-500 text-sm">{state.error}</p>
)}
{state.success && (
  <p className="text-green-500 text-sm">{state.success}</p>
)}
{state.error && (
  <p role="alert" className="text-red-500 text-sm">{state.error}</p>
)}
{state.success && (
  <p role="status" className="text-green-500 text-sm">{state.success}</p>
)}
Accessibility·app/(dashboard)/dashboard/activity/page.tsx:92Serious

Decorative activity icons announce noise to screen reader users

Each list row renders `<Icon className="w-5 h-5 text-orange-600" />` right next to its own text label (e.g. "You signed in") with no `aria-hidden`.

Why it matters

Without aria-hidden, screen readers announce each icon's SVG title or a generic 'image' before the text it duplicates, adding noise to every single row in the log.

Fix

Mark purely decorative icons aria-hidden='true' when adjacent text already conveys the meaning.

<Icon className="w-5 h-5 text-orange-600" />
<Icon className="w-5 h-5 text-orange-600" aria-hidden="true" />
Accessibility·app/(dashboard)/dashboard/layout.tsx:52Serious

Nav links nest a Button inside a Link, breaking interactive semantics

Each of the four nav items ("Team", "General", "Activity", "Security") wraps a `<Button>` inside `<Link passHref>`. If `Button` renders a native `<button>`, this produces `<a><button></button></a>`, an interactive element nested inside another interactive element.

Why it matters

Nested interactive elements produce invalid DOM that browsers and screen readers resolve inconsistently: keyboard focus can land on the wrong element or fire twice, so nav item activation becomes unreliable for keyboard and assistive tech users.

Fix

Use the Button's asChild/Slot pattern so the Link itself becomes the single interactive element.

<Link key={item.href} href={item.href} passHref>
  <Button
    variant={pathname === item.href ? 'secondary' : 'ghost'}
    className={`shadow-none my-1 w-full justify-start ${
      pathname === item.href ? 'bg-gray-100' : ''
    }`}
    onClick={() => setIsSidebarOpen(false)}
  >
    <item.icon className="h-4 w-4" />
    {item.label}
  </Button>
</Link>
<Button
  key={item.href}
  asChild
  variant={pathname === item.href ? 'secondary' : 'ghost'}
  className={`shadow-none my-1 w-full justify-start ${
    pathname === item.href ? 'bg-gray-100' : ''
  }`}
  onClick={() => setIsSidebarOpen(false)}
>
  <Link href={item.href}>
    <item.icon className="h-4 w-4" />
    {item.label}
  </Link>
</Button>
Accessibility·app/(dashboard)/dashboard/page.tsx:220Serious

Decorative icons on the invite button are read aloud by screen readers

`<PlusCircle className="mr-2 h-4 w-4" />` sits next to the visible text "Invite Member", and `<Loader2 className="mr-2 h-4 w-4 animate-spin" />` sits next to "Inviting...": neither icon has `aria-hidden`.

Why it matters

Screen readers announce these icons as redundant noise (an unlabeled image or the raw SVG) right before reading the label that already says the same thing, adding clutter to every announcement of this button.

Fix

Mark purely decorative icons aria-hidden when they sit beside a text label that already conveys the meaning.

{isInvitePending ? (
  <>
    <Loader2 className="mr-2 h-4 w-4 animate-spin" />
    Inviting...
  </>
) : (
  <>
    <PlusCircle className="mr-2 h-4 w-4" />
    Invite Member
  </>
)}
{isInvitePending ? (
  <>
    <Loader2 aria-hidden="true" className="mr-2 h-4 w-4 animate-spin" />
    Inviting...
  </>
) : (
  <>
    <PlusCircle aria-hidden="true" className="mr-2 h-4 w-4" />
    Invite Member
  </>
)}
97/100

UX

1 critical
UX·app/(dashboard)/dashboard/security/page.tsx:155Critical

Delete Account submits immediately with no confirmation step

The "Delete Account" form asks only for a password, then the "Delete Account" button calls `deleteAction` directly on submit. There is no confirmation dialog, typed-confirmation phrase, or undo path before the irreversible action fires.

Why it matters

A single accidental click plus an already-filled password field permanently deletes the account with no recovery step, which is the exact failure mode account-deletion confirmations exist to prevent.

Fix

Wrap the destructive submit in a confirmation dialog (AlertDialog) that restates the consequence before calling the action.

<Button
  type="submit"
  variant="destructive"
  className="bg-red-600 hover:bg-red-700"
  disabled={isDeletePending}
>
<AlertDialog>
  <AlertDialogTrigger asChild>
    <Button type="button" variant="destructive" disabled={isDeletePending}>
      <Trash2 className="mr-2 h-4 w-4" />
      Delete Account
    </Button>
  </AlertDialogTrigger>
  <AlertDialogContent>
    <AlertDialogTitle>Delete your account?</AlertDialogTitle>
    <AlertDialogDescription>This permanently deletes your account and cannot be undone.</AlertDialogDescription>
    <AlertDialogAction onClick={() => deleteAction(new FormData())}>Delete account</AlertDialogAction>
  </AlertDialogContent>
</AlertDialog>

Typography

No issues found

Color

No issues found

Spacing

No issues found

Components

No issues found

Motion

No issues found

Craft

No issues found

Working well

  • The invite form disables both the email input and the radio group when `!isOwner`, and adds a footer message explaining why ("You must be a team owner to invite new members."). Disabling the control and explaining the reason in the same view is the correct pattern: it tells the non-owner what's happening instead of leaving a mysteriously grayed-out form.
  • Using SWRConfig with a server-populated fallback ({'/api/user': getUser(), '/api/team': getTeamForUser()}) without awaiting is a solid pattern: it lets the shell render immediately while only the components that actually read that data suspend, which keeps time-to-first-paint fast without blocking on auth/team lookups.
  • Both inputs use `<Label htmlFor="name">` / `<Label htmlFor="email">` paired with matching `id` attributes on the inputs. Explicit label association means screen readers announce the field name and clicking the label focuses the right input, which is easy to skip but does real work for usability.
  • One `<aside>` handles both the desktop rail (`lg:block lg:relative lg:translate-x-0`) and the mobile drawer (`absolute` + translate toggle) instead of maintaining two separate sidebar components. That's good reuse: one source of truth for nav items means the list can't drift between breakpoints.

Score your own repo with Rams.

Free on public repos. Rams reviews every pull request for accessibility, color, type, spacing, components, motion, UX, and craft, and posts inline fixes.