nextjs/saas-starter
Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.
24 files reviewed·July 9, 2026
More findings
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
Accessibility
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
};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>
)}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" />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>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
</>
)}UX
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
Color
Spacing
Components
Motion
Craft
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.