Cursor builds production SaaS features well when it works on a codebase that already has auth, billing, and multi-tenancy, and when it gets the project's rules and the right context files. Vague prompts and missing context are where it goes wrong. Below is the workflow I use with Cursor on MakerKit: a database schema with pgTAP tests, a team page, data fetching, and a form backed by a Server Action.
Quick answer: To build a SaaS with Cursor, open a MakerKit codebase in Cursor and use Agent mode. Cursor loads MakerKit's nested AGENTS.md files automatically, so it follows the kit's conventions for Server Actions, Supabase queries, and translations. Connect the MakerKit MCP server so Cursor reads your real schema and migrations. Prompt one task at a time, attach the relevant files, and verify every change with pnpm typecheck and tests.
Updated October 2026 (originally published January 2025): Cursor reads nested AGENTS.md files, so MakerKit's per-package rules load without extra setup. The MCP setup uses the MCP server that ships inside the kit, the code samples use authActionClient, next-intl translation keys, [locale] routes and Next.js 16 awaited params, and the "What Changed" section covers cloud agents, Bugbot and current Cursor plans. The screenshots are from the original walkthrough. For a tool-by-tool comparison, see Best Vibe Coding Tools for SaaS Development and Claude Code Best Practices.
Click to expandMany MakerKit customers build their SaaS with Cursor. That pushed me to write a set of rules that improve Cursor's ability to work within a MakerKit project, and to find where Cursor falls short so we can fix it.
Cursor generates and explains code well, but you still make the decisions. You need to ask precise questions, understand the answers, and notice when Cursor proposes a wrong solution.
Using Windsurf instead? Cognition renamed it Devin Desktop in June 2026, and the Windsurf version of this post still applies to the editor workflow.
Disclaimer: I will be the average MakerKit user
In some of my interactions with Cursor, I will use as little knowledge as possible with regard to the code.
I am putting myself in the role of a new customer, not an expert in the codebase. This is to ensure that I can interact with Cursor just like a regular customer would.
Some of my prompts may be naive or even incorrect, this is also intentional. I don't want to make any assumptions about the codebase or the developer's knowledge, but I want to provide the best possible experience for the user.
I will also make as few manual changes as possible to the codebase.
What You'll Learn
New to MakerKit? Start with the free MakerKit course to learn the fundamentals.
- How to effectively use Cursor with MakerKit's architecture
- Creating a database schema with Cursor and creating the relative tests
- Improve your questions using a multi-step approach
- Building a page with Cursor
- Building forms with Cursor
- Reacting to forms with Server Actions
- Tips for maintaining code quality with AI assistance
Cursor Rules for MakerKit
Cursor reads instructions from these places:
AGENTS.mdfiles at the repository root and in subdirectories. Instructions from a nestedAGENTS.mdare combined with the ones from its parent directories. The same files are read by Claude Code (throughCLAUDE.md), Codex, and Devin Desktop.- Project rules in
.cursor/rules/*.mdc, scoped rule files that can attach automatically based on glob patterns. - User rules (your global preferences in Cursor's settings) and Team rules (managed from the dashboard on Teams and Enterprise plans).
The old single-file .cursorrules format no longer appears in Cursor's rules documentation. If you have one from an older project, move its content into AGENTS.md.
MakerKit does not ship a .cursorrules file or a .cursor/rules folder, because AGENTS.md covers Cursor. The kit ships a root AGENTS.md plus nested files in apps/web, apps/web/supabase, apps/e2e, the admin route, and the main packages/* (ui, supabase, next, features, policies, and others), so Cursor pulls the right context based on the file you're editing. The rules cover:
- Project structure and conventions
- Common patterns and components (
authActionClientfrom@kit/next/safe-action,enhanceRouteHandlerfrom@kit/next/routes,getSupabaseServerClient,Trans) - Import paths and aliases (
@kit/ui,@kit/supabase,@kit/next) - Type definitions and schemas
The kit also ships a .cursorignore that keeps generated files such as database.types.ts, Playwright reports, and .env.local files out of Cursor's context. Alongside the Cursor setup, it includes CLAUDE.md and skills in .claude/skills for Claude Code, plus an .mcp.json with the MakerKit MCP server. The AI agents documentation lists every file and which tool reads it.
Tip: Keep AGENTS.md updated with your SaaS business logic. Cursor loads it with every prompt, so this edit improves every answer.
Enable Agent Mode and MCP
Cursor's Agent mode runs multi-step tasks: it reads files, edits several of them, and runs terminal commands until the task is done. Pair it with the MakerKit MCP server, which ships inside the kit in packages/mcp-server. It gives Cursor tools to read your schema files and migrations, create new migrations, manage translation namespaces, and look up the kit's UI components, so you don't have to paste that context by hand.
Build the server once:
pnpm --filter "@kit/mcp-server" buildThe kit's .mcp.json registers the server for Claude Code. Cursor reads project MCP servers from .cursor/mcp.json, so add the same entry there:
{ "mcpServers": { "makerkit": { "command": "node", "args": ["packages/mcp-server/build/index.cjs"] } }}If Cursor does not connect with the relative path, replace it with the absolute path to index.cjs.
Learn more about the agent rules and MCP server for MakerKit.
Practical Examples of using MakerKit with an LLM
In the following sections, I will show you how to use MakerKit and Cursor to build common tasks such as:
- Database: building, refining and testing a Postgres/Supabase schema with Cursor based on MakerKit's schema
- Building a Page: building a page with Cursor with the same layout as MakerKit's other pages
- Data Fetching: fetching data from Supabase using Cursor
- Building Forms with Cursor: building forms with Cursor and using Zod to validate the data
- Reacting to Forms with Server Actions: using Cursor to build forms and then using Server Actions to handle the data
Q&A about the codebase
Cursor can be a great assistant to help you understand the codebase better and faster. As familiarizing with a codebase can be challenging, let's see how we can use Cursor to help us understand the codebase.
Q1. How is the project organized and where should I add my new features?
I asked Cursor to explain the project organization and where to add new features. Here's the response:
Let me break down the project organization and explain where to add new features.<CODE_REVIEW>The project follows a Turborepo monorepo structure with key areas:1. Main Application (`apps/web/`): - Uses Next.js App Router with a clear separation of concerns - Routes are organized under `app/home` with two main contexts: - `(user)`: Personal workspace - `[account]`: Team workspace - Each feature area has its own directory structure: ``` feature/ ├── _components/ # React components ├── _lib/ # Feature-specific logic │ ├── schema/ # Zod schemas │ └── server/ # Server actions & loaders └── page.tsx # Page component ```2. Shared Packages (`packages/`): - `ui`: Reusable UI components - `features`: Shared business logic - Other utility packages3. Configuration: - Navigation: `apps/web/config/` - i18n: `apps/web/public/locales/`</CODE_REVIEW><PLANNING>When adding a new feature, follow these steps:1. Choose the correct context: - Personal features go under `apps/web/app/home/(user)/` - Team features go under `apps/web/app/home/[account]/`2. Create the feature directory structure: ``` your-feature/ ├── _components/ │ └── your-component.tsx ├── _lib/ │ ├── schema/ │ │ └── your-schema.ts │ └── server/ │ ├── server-actions.ts │ └── page-loader.ts └── page.tsx ```3. Update supporting files: - Add navigation items to the appropriate config file - Add translations to `apps/web/public/locales/en/common.json` - Create new translation namespaces if needed4. For shared functionality: - Add to `packages/features/` if it's business logic - Add to `packages/ui/` if it's a reusable UI component</PLANNING>Key considerations:1. **Data Flow**: - Server Components: Use Supabase client directly - Client Components: Use React Query for data fetching - Forms: Use React Hook Form + Zod validation2. **Security**: - Always use `enhanceAction` for server actions - Always use `enhanceRouteHandler` for API routes - Consider RLS policies for Supabase tables3. **Best Practices**: - Follow the naming conventions (kebab-case for files, camelCase for functions) - Use TypeScript strictly (avoid any/unknown) - Implement proper error handling - Add translations using the `Trans` componentWould you like me to elaborate on any specific aspect of the organization or provide examples for implementing a particular type of feature?The answer was accurate: it described the project structure correctly and pointed to the right folders for new features.
The structure is the same, but a few paths and APIs moved. Routes now live under apps/web/app/[locale]/home/(user) and apps/web/app/[locale]/home/[account]. Translations are next-intl JSON files in apps/web/i18n/messages/en/. Server Actions use authActionClient from @kit/next/safe-action instead of enhanceAction. With the current AGENTS.md files loaded, Cursor answers with these paths.
Q2. Where can I update the sidebar of the personal account?
I asked Cursor to help me update the sidebar of the personal account - however, the response was not very helpful. While it was aware of where the sidebar configuration was located, it ultimately decided to change much of its structure, and make up some types that did not exist.
How to improve the response? Give it the context file!
This may sound obvious, but Cursor works a lot better when provided with the context files. In the case above, I manually provided the configuration file apps/web/config/personal-account-navigation.config.tsx to Cursor, which allowed it to provide a more accurate response.
When you ask these questions, you can split them into two parts:
- Context: Which files are responsible for the sidebar configuration? Verify the answer to be correct
- Instruction: Manually provide the files to Cursor and follow up with instructions on how to improve the response
With the two-step approach above, Cursor was able to understand the sidebar configuration and also update the sidebar for me in Agent mode.
Database: building a Postgres schema with Cursor
Creating a database schema is like architecting the foundation of your house - get it wrong, and everything built on top becomes unstable. We need to ensure our schema is:
- Secure and properly access-controlled
- Performant under load
- Consistent with MakerKit's patterns
- Easy to maintain and extend
By the end of this section, you'll learn:
- How to effectively prompt AI for schema design
- What makes a good database schema
- How to validate AI-generated schemas
- Common pitfalls to avoid
Stating the business logic of the app in the Cursor rules
The first step is to clearly state the business logic of the app in the Cursor rules. This will help Cursor understand the context and provide the most relevant information.
For example, if you are building a SaaS for managing a to-do list, you might want to include the following in the Cursor rules:
The app should allow users to create, update, and delete to-do items. Users should be able to mark to-do items as completed. The app should also allow users to filter to-do items by status (e.g., completed, pending).
This will help Cursor understand the context and provide the most relevant information. By stating the business logic of the app, Cursor will be able to provide more accurate and relevant information.
For example, when creating the Database schema, Cursor will be able to understand that the app is a to-do list management app, and that it should include tables for to-do items, users, and their relationships.
In my case, I stated the below:
## Application ScopeWe are building a SaaS application that allows teams to bookmark and organizelinks. Our database and code need to be structured in a way that makes iteasy to add new features and improve the existing ones, while supporting theability to build the functionalities of this application.While the above is in no way a complete or accurate description of the application, it is a good starting point.
1. First Attempt: The Naive Approach
My first attempt was simple but too vague:
I want to start writing the required database schema for supporting the app's functionality. Brainstorm the schema with me.
Result: While Cursor provided decent output, competing AI tool Windsurf (now Devin Desktop) missed the mark by suggesting tables MakerKit already provides (like users and accounts), which showed it didn't understand our context.
2. Second Attempt: The Refined Prompt
Learning from this, I crafted a more detailed prompt:
I want to start writing the required database schema for supporting the app's functionality. Brainstorm the schema with me.Requirements:1. Only include new tables needed for app functionality2. Team accounts own tables (not individual users)3. Ensure proper team member access control4. Include appropriate indexes5. Define table relationships6. Consider performance implications7. Ensure schema safety8. Maintain consistency9. Reuse existing schema functions10. Reference accounts table (not auth.users)In the prompt above, I've added some additional requirements to ensure the schema is secure, performant, consistent, and easy to maintain
- Focus: The AI may want to try create tables that exist (users, accounts, etc.). Best to avoid this.
- Accounts: You want to rarely use the
auth.userstable, and instead use thepublic.accountstable - Permissions: You want to use
public.has_role_on_accountto check permissions, a function built-in to MakerKit. - Indexes: You want to include appropriate indexes for performance
- Relationships: You want to define table relationships, such as
public.accountstopublic.accounts_memberships - Performance: You want to consider performance implications, such as adding appropriate indexes
Key insight: By providing MakerKit's existing SQL schema as context, the AI could better understand and reuse existing patterns. When creating a DB schema, always provide the existing schema files as context to the AI. In the current kit these are the numbered declarative files in apps/web/supabase/schemas/ (for example 03-accounts.sql and 05-memberships.sql), and the MakerKit MCP server can read them for Cursor. The apps/web/supabase/AGENTS.md file tells Cursor to write a new schema file there and generate the migration from it with db:diff.
Result of the Refined Prompt
This time Cursor's output was correct on every point I cared about:
- Account Ownership: Correctly linked tables to
public.accounts - Access Control: Used
public.has_role_on_accountfor permissions - Security: Created proper RLS policies
- Automation: Added triggers for timestamps
- Performance: Included appropriate indexes
/* * ------------------------------------------------------- * Bookmarks Schema * This migration creates the schema for the bookmarking functionality * including collections, bookmarks, tags, and their relationships * ------------------------------------------------------- */-- Collections tablecreate table if not exists public.collections ( id uuid primary key default extensions.uuid_generate_v4(), account_id uuid references public.accounts(id) on delete cascade not null, name varchar(255) not null, description text, created_at timestamptz default current_timestamp not null, updated_at timestamptz default current_timestamp not null, created_by uuid references public.accounts(id), updated_by uuid references public.accounts(id));comment on table public.collections is 'Collections group bookmarks together';comment on column public.collections.account_id is 'The team account that owns this collection';-- Bookmarks tablecreate table if not exists public.bookmarks ( id uuid primary key default extensions.uuid_generate_v4(), account_id uuid references public.accounts(id) on delete cascade not null, collection_id uuid references public.collections(id) on delete set null, url text not null, title varchar(255) not null, description text, favicon_url text, created_at timestamptz default current_timestamp not null, updated_at timestamptz default current_timestamp not null, created_by uuid references public.accounts(id), updated_by uuid references public.accounts(id));comment on table public.bookmarks is 'Stores bookmarks for team accounts';comment on column public.bookmarks.account_id is 'The team account that owns this bookmark';comment on column public.bookmarks.collection_id is 'Optional collection this bookmark belongs to';-- Tags tablecreate table if not exists public.tags ( id uuid primary key default extensions.uuid_generate_v4(), account_id uuid references public.accounts(id) on delete cascade not null, name varchar(50) not null, created_at timestamptz default current_timestamp not null, unique(account_id, name));comment on table public.tags is 'Tags for categorizing bookmarks';comment on column public.tags.account_id is 'The team account that owns this tag';-- Bookmark Tags junction tablecreate table if not exists public.bookmark_tags ( bookmark_id uuid references public.bookmarks(id) on delete cascade not null, tag_id uuid references public.tags(id) on delete cascade not null, primary key (bookmark_id, tag_id));comment on table public.bookmark_tags is 'Junction table connecting bookmarks to tags';-- Indexescreate index if not exists ix_bookmarks_account_id on public.bookmarks(account_id);create index if not exists ix_bookmarks_collection_id on public.bookmarks(collection_id);create index if not exists ix_bookmarks_created_at on public.bookmarks(created_at);create index if not exists ix_collections_account_id on public.collections(account_id);create index if not exists ix_tags_account_id on public.tags(account_id);create index if not exists ix_bookmark_tags_tag_id on public.bookmark_tags(tag_id);-- Add triggers for updating timestampscreate trigger set_timestamp before update on public.collections for each row execute procedure public.trigger_set_timestamps();create trigger set_timestamp before update on public.bookmarks for each row execute procedure public.trigger_set_timestamps();-- Add triggers for user trackingcreate trigger set_user_tracking before insert or update on public.collections for each row execute procedure public.trigger_set_user_tracking();create trigger set_user_tracking before insert or update on public.bookmarks for each row execute procedure public.trigger_set_user_tracking();-- Enable RLSalter table public.collections enable row level security;alter table public.bookmarks enable row level security;alter table public.tags enable row level security;alter table public.bookmark_tags enable row level security;-- Revoke all on tables from publicrevoke all on public.collections from anon, authenticated;revoke all on public.bookmarks from anon, authenticated;revoke all on public.tags from anon, authenticated;revoke all on public.bookmark_tags from anon, authenticated;-- Grant access to authenticated usersgrant select, insert, update, delete on public.collections to authenticated;grant select, insert, update, delete on public.bookmarks to authenticated;grant select, insert, update, delete on public.tags to authenticated;grant select, insert, update, delete on public.bookmark_tags to authenticated;-- RLS Policiescreate policy "Team members can read collections" on public.collections for select using (public.has_role_on_account(account_id));create policy "Team members can insert collections" on public.collections for insert with check (public.has_role_on_account(account_id));create policy "Team members can update collections" on public.collections for update using (public.has_role_on_account(account_id)) with check (public.has_role_on_account(account_id));create policy "Team members can delete collections" on public.collections for delete using (public.has_role_on_account(account_id));create policy "Team members can read bookmarks" on public.bookmarks for select using (public.has_role_on_account(account_id));create policy "Team members can insert bookmarks" on public.bookmarks for insert with check (public.has_role_on_account(account_id));create policy "Team members can update bookmarks" on public.bookmarks for update using (public.has_role_on_account(account_id)) with check (public.has_role_on_account(account_id));create policy "Team members can delete bookmarks" on public.bookmarks for delete using (public.has_role_on_account(account_id));create policy "Team members can read tags" on public.tags for select using (public.has_role_on_account(account_id));create policy "Team members can insert tags" on public.tags for insert with check (public.has_role_on_account(account_id));create policy "Team members can update tags" on public.tags for update using (public.has_role_on_account(account_id)) with check (public.has_role_on_account(account_id));create policy "Team members can delete tags" on public.tags for delete using (public.has_role_on_account(account_id));create policy "Team members can read bookmark tags" on public.bookmark_tags for select using (exists ( select 1 from public.bookmarks where id = bookmark_id and public.has_role_on_account(account_id) ));create policy "Team members can insert bookmark tags" on public.bookmark_tags for insert with check (exists ( select 1 from public.bookmarks where id = bookmark_id and public.has_role_on_account(account_id) ));create policy "Team members can delete bookmark tags" on public.bookmark_tags for delete using (exists ( select 1 from public.bookmarks where id = bookmark_id and public.has_role_on_account(account_id) ));Validating the Database schema using Tests
You can also ask Cursor to bootstrap testing cases for you to validate the database schema. pgTap is a popular testing framework for Postgres, and we already use it in MakerKit. This gives Cursor a lot of information to work with.
This can be done by adding the following to the prompt:
Can you create some pgTap tests for this schema?
Click to expandAs Context I provided it with two files:
- Schema: the migration file generated by the AI
- Tests example: an existing pgTap test file that tests other parts of the schema that Cursor can use as a reference
You can then ask Cursor to apply the changes and add the test file to tests directory. MakerKit will then be able to run the tests and ensure the database schema is valid according to the tests.
Please make sure to add more test cases so that the database schema is validated also by yourself.
Building Pages with Cursor
Now that we have a database schema, we can start building the pages that will support the app's functionality. In this section, we will learn how to build a page with Cursor.
Understanding the User vs the Team contexts
MakerKit has two contexts for the user and the team. The user context is used for personal accounts, and the team context is used for team accounts.
Always tell Cursor which context you are working with. This will help Cursor understand:
- Where to place the files it needs to add or edit
- What functionality it can and cannot provide
When adding a new page, for example, always specify the context in which you want the functionality to be added.
I want to add a new page to the app. The page should be added to the personal account context. The page should be named "Bookmarks".
Asking Cursor to create a page
When starting functionalities from scratch, I found it's important to go step by step, instead of big bang approaches.
As a first step, I asked Cursor to create a page for me:
Let's build the Bookmarks list page. This page should live under the Team workspace (since it's shared among members). As the first step, I want us to build a page without any functionality, simply a skeleton where we're going to add a list of bookmarks.
Cursor got most of it right:
- Correct: the page was created under the team workspace, at
app/[locale]/home/[account]/bookmarks/page.tsx, following Next.js App Router conventions - Correct: the page was created with the correct metadata
- Wrong: the page did not use the same layout as the existing pages
To fix the above, I added an existing page under the Team Account context to the Chat, which allowed Cursor to understand the layout of the page and do a much better job!
Click to expandData Fetching
Now that we have the page created, we want to ask Cursor to help us with the data fetching items from the Supabase database and display them in the UI.
Let's get started by asking Cursor how we can fetch the data from the Supabase from our newly created bookmarks table. For the time being, I only want to understand how to fetch the data and how Cursor can help me with that.
Therefore I start a fresh new chat with Cursor:
I want to fetch the data from the bookmarks table and display it in the UI. How can we use the Supabase Client to fetch the data?
Cursor responds with a correct but basic snippet:
- Import the
getSupabaseServerClientfunction from the@kit/supabase/server-clientpackage - Use the
frommethod to select the data from thebookmarkstable - Use the
ordermethod to sort the data in descending order - Use the
selectmethod to select only the necessary columns
Unfortunately, it didn't do a good job at filtering the data by the selected account ID. We can do better with our prompt here.
I want to fetch the data from the bookmarks table and display it in the UI. I want to filter the data by the selected account ID. By using the slug from the [account] parameter, I want to filter the data by the selected account ID. We can use a join to match the slug using the account_id column.
This prompt is much better! It gives Cursor a lot of information to work with:
- The
accountparameter is passed to the page, and it is used to filter the data - We use a join to match the slug using the
account_idcolumn
Here's the updated code:
import { Metadata } from 'next';import { notFound } from 'next/navigation';import { getSupabaseServerClient } from '@kit/supabase/server-client';import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';import { PageBody } from '@kit/ui/page';import { Trans } from '@kit/ui/trans';import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header';import BookmarksList from './_components/bookmarks-list';export const metadata: Metadata = { title: 'Bookmarks', description: 'View and manage your bookmarks',};async function BookmarksPage({ params,}: { params: Promise<{ account: string }>;}) { const { account } = await params; const client = getSupabaseServerClient(); const { data: bookmarks, error } = await client .from('bookmarks') .select( ` *, accounts!bookmarks_account_id_fkey!inner(slug) `, ) .eq('accounts.slug', account); if (error) { console.error('Error fetching bookmarks:', error); return notFound(); } return ( <PageBody> <TeamAccountLayoutPageHeader account={account} title={<Trans i18nKey={'common.routes.bookmarks'} />} description={<AppBreadcrumbs />} /> <BookmarksList bookmarks={bookmarks} /> </PageBody> );}export default BookmarksPage;The !inner modifier matters: without it, the .eq('accounts.slug', account) filter only empties the embedded accounts object and still returns bookmarks from every team the user belongs to.
On Next.js 16, params is a Promise: you must await it in Server Components (or unwrap it with use()). Older Cursor outputs may still read params.account synchronously, so correct it the moment you see it. The MakerKit AGENTS.md files document this pattern so Cursor generates it correctly when the rules are loaded. The current kit also no longer wraps pages in withI18n, and translation keys use dots (common.routes.bookmarks) because the kit uses next-intl.
Fixing Mistakes with Cursor
In the above, Cursor made a mistake in querying the data:
.select(` *, accounts!inner(slug)`);Supabase returned the following error:
"Try changing 'accounts' to one of the following: 'accounts! bookmarks_account_id_fkey', 'accounts!bookmarks_created_by_fkey', 'accounts!bookmarks_updated_by_fkey'. Find the desired relationship in the 'details' key.
I highlighted the error and asked Cursor to fix it by attaching the hint from Supabase:
Click to expandThe hint was very helpful! Cursor successfully fixed the error and the query now works as expected.
Improving the code
Cursor's code above is functionally correct, however, it is not following MakerKit's conventions. We can ask Cursor to help us improve the code by separating the concerns of fetching data and displaying it in the UI.
For example, we can ask Cursor to follow MakerKit's conventions and add the query to a service in _lib/server/bookmarks.service.ts, then call the service from the Server Component and pass the data to the Client Component. We do the same for the insert in the form section below.
It is optional for a page this small, but it keeps data access in one place as the feature grows.
Displaying the Data in the UI
Now that we have the data fetched, we can display it in the UI. Cursor had already provided me with a simple component to display the data:
'use client';import { Database } from '@kit/supabase/database';type Bookmark = Database['public']['Tables']['bookmarks']['Row'];type BookmarksListProps = { bookmarks: Bookmark[];};export default function BookmarksList({ bookmarks }: BookmarksListProps) { if (!bookmarks.length) { return ( <div className="text-center text-muted-foreground py-8"> No bookmarks found </div> ); } return ( <div className="grid gap-4"> {bookmarks.map((bookmark) => ( <div key={bookmark.id} className="p-4 rounded-lg border bg-card text-card-foreground" > <h2 className="text-lg font-semibold"> <a href={bookmark.url} target="_blank" rel="noopener noreferrer" className="hover:underline" > {bookmark.title} </a> </h2> {bookmark.description && ( <p className="text-muted-foreground mt-2">{bookmark.description}</p> )} </div> ))} </div> );}In the above, Cursor provided the following:
- Page: the page was correctly created under the team workspace, at
app/[locale]/home/[account]/bookmarks/page.tsxand following Next.js App Router conventions - Metadata: the page was created with the correct metadata
- Bookmarks Data: the page fetched the data from the
bookmarkstable and displayed it in the UI - Filtering Data: the page filtered the data by the selected account ID
- List Component: the page used the
BookmarksListcomponent to display the bookmarks
In just 2 prompts, Cursor was able to build a working (while primitive) page with the data fetching and displaying.
From here, we can ask Cursor a variety of questions to improve the page, such as:
- Using the
Tablecomponent to display the bookmarks - Improve the UI/UX using any of the components from Shadcn UI
- Adding a search bar to filter the bookmarks
- Adding pagination to the list of bookmarks
In the next section, we will learn how to build forms with Cursor.
Building Forms with Cursor
The next step in our UI is creating the forms to add a new bookmark. We can ask Cursor to help us with that.
To do so, I start a new chat with Cursor:
I now want to add a button that opens a Dialog. The Dialog will contain a form that allows us to insert a bookmark in the DB using a Server Action. Please use a separate client component for the Dialog and the Form.
Cursor responds with 95% of the code ready to go, with a small mistake using the Header component in the page, which I was required to fix manually.
Click to expandLet's take a look at the code:
'use client';import { PlusIcon } from 'lucide-react';import { Button } from '@kit/ui/button';import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,} from '@kit/ui/dialog';import { Trans } from '@kit/ui/trans';import { CreateBookmarkForm } from './create-bookmark-form';export function CreateBookmarkDialog() { return ( <Dialog> <DialogTrigger render={ <Button> <PlusIcon className="mr-2 h-4 w-4" /> <Trans i18nKey="bookmarks.actions.createBookmark" /> </Button> } /> <DialogContent> <DialogHeader> <DialogTitle> <Trans i18nKey="bookmarks.createDialog.title" /> </DialogTitle> </DialogHeader> <CreateBookmarkForm /> </DialogContent> </Dialog> );}And the form:
'use client';import { zodResolver } from '@hookform/resolvers/zod';import { useTranslations } from 'next-intl';import { useAction } from 'next-safe-action/hooks';import { useForm } from 'react-hook-form';import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';import { Button } from '@kit/ui/button';import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage,} from '@kit/ui/form';import { Input } from '@kit/ui/input';import { toast } from '@kit/ui/sonner';import { Trans } from '@kit/ui/trans';import { CreateBookmarkSchema } from '../_lib/schema/create-bookmark.schema';import { createBookmarkAction } from '../_lib/server/server-actions';export function CreateBookmarkForm(props: { onSuccess?: () => void }) { const { account } = useTeamAccountWorkspace(); const t = useTranslations('bookmarks'); const form = useForm({ resolver: zodResolver(CreateBookmarkSchema), defaultValues: { url: '', title: '', description: '', accountId: account.id, }, }); const { execute, isPending } = useAction(createBookmarkAction, { onSuccess: () => { form.reset(); props.onSuccess?.(); }, onError: () => { toast.error(t('createBookmarkError')); }, }); const onSubmit = form.handleSubmit((data) => execute(data)); return ( <Form {...form}> <form onSubmit={onSubmit} className="space-y-4"> <FormField control={form.control} name="url" render={({ field }) => ( <FormItem> <FormLabel> <Trans i18nKey="bookmarks.form.urlLabel" /> </FormLabel> <FormControl> <Input placeholder="https://example.com" type="url" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="title" render={({ field }) => ( <FormItem> <FormLabel> <Trans i18nKey="bookmarks.form.titleLabel" /> </FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="description" render={({ field }) => ( <FormItem> <FormLabel> <Trans i18nKey="bookmarks.form.descriptionLabel" /> </FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <div className="flex justify-end"> <Button type="submit" disabled={isPending}> <Trans i18nKey="bookmarks.form.submitButton" /> </Button> </div> </form> </Form> );}Cursor also (correctly) provided the following Zod schema:
import { z } from 'zod';export const CreateBookmarkSchema = z.object({ url: z.string().url(), title: z.string().min(1), description: z.string().optional(), accountId: z.string().uuid(),});export type CreateBookmarkData = z.infer<typeof CreateBookmarkSchema>;Cursor's first version put the insert inside the Server Action. packages/next/AGENTS.md says actions must not contain business logic, so I asked Cursor to move the insert into a service, following the createTeamBillingService(client) pattern in the kit's billing route:
import 'server-only';import { SupabaseClient } from '@supabase/supabase-js';import { getLogger } from '@kit/shared/logger';import { Database } from '~/lib/database.types';import { CreateBookmarkData } from '../schema/create-bookmark.schema';export function createBookmarksService(client: SupabaseClient<Database>) { return new BookmarksService(client);}class BookmarksService { constructor(private readonly client: SupabaseClient<Database>) {} async createBookmark(data: CreateBookmarkData) { const { error } = await this.client.from('bookmarks').insert({ url: data.url, title: data.title, description: data.description, account_id: data.accountId, }); if (error) { const logger = await getLogger(); logger.error({ error, accountId: data.accountId }, 'Failed to create bookmark'); throw new Error('bookmarks.createBookmarkError'); } }}The Server Action now only wires the authenticated client to the service and revalidates the page:
'use server';import { revalidatePath } from 'next/cache';import { authActionClient } from '@kit/next/safe-action';import { getSupabaseServerClient } from '@kit/supabase/server-client';import { CreateBookmarkSchema } from '../schema/create-bookmark.schema';import { createBookmarksService } from './bookmarks.service';export const createBookmarkAction = authActionClient .inputSchema(CreateBookmarkSchema) .action(async ({ parsedInput: data }) => { const client = getSupabaseServerClient(); const service = createBookmarksService(client); await service.createBookmark(data); revalidatePath('/home/[account]/bookmarks', 'page'); return { success: true, }; });This split follows the rules in packages/next/AGENTS.md:
authActionClientvalidates the input against the Zod schema and rejects unauthenticated requests before the function runs.- The insert lives in
bookmarks.service.ts, so the same logic can be reused from a route handler, an MCP tool, or a test. - The service logs the database error on the server and throws a translation key, because the message of any thrown error is sent back to the browser.
- RLS checks
account_idagainst the user's memberships, so the action does not need a manual permission check.
On the client, useAction from next-safe-action/hooks exposes execute and isPending, so the form does not need useTransition. The onError callback shows the bookmarks.createBookmarkError message in a toast from @kit/ui/sonner, the same pattern the kit uses in its team settings forms.
Translating the UI
Since Cursor (correctly) added the translation keys, I asked it to add the bookmarks namespace and to add the JSON file for the new namespace:
Add the new translations namespace for the bookmarks functionality We now need to add the namespace json file with the translations in the bookmarks components
I included as context the i18n.settings.ts file containing the settings for the translations.
In the current kit, translations are next-intl JSON files in apps/web/i18n/messages/en/, and the list of namespaces to load lives in apps/web/i18n/request.ts. With the MCP server connected, Cursor can also create the namespace with the kit_translations_add_namespace tool instead of writing the file by hand.
The namespace file holds every key the components use, including createBookmarkError, the key the service throws and the form's onError toast displays:
{ "actions": { "createBookmark": "New Bookmark" }, "createDialog": { "title": "Add a bookmark" }, "form": { "urlLabel": "URL", "titleLabel": "Title", "descriptionLabel": "Description", "submitButton": "Save Bookmark" }, "createBookmarkError": "We couldn't save this bookmark. Please try again."}Then add 'bookmarks' to the namespaces array in apps/web/i18n/request.ts so next-intl loads it.
Fixing a bug in the Dialog component
While the submission was working correctly, I noticed that the dialog was not closing automatically after the submission. I asked Cursor to fix this bug:
Click to expandHow to close the dialog after a successful submission?
- In the first attempt, I did not provide the
dialog.tsxfile as prompt context. As you can imagine, Cursor was not able to understand the context of the file and was not able to provide the correct code. - In the second attempt, I provided the
dialog.tsxfile as prompt context. This time, Cursor understood the context and provided the correct code.
Cursor fixed the issue reusing the open and onOpenChange props from the Dialog component:
'use client';import { useState } from 'react';import { PlusIcon } from 'lucide-react';import { Button } from '@kit/ui/button';import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,} from '@kit/ui/dialog';import { Trans } from '@kit/ui/trans';import { CreateBookmarkForm } from './create-bookmark-form';export function CreateBookmarkDialog() { const [open, setOpen] = useState(false); return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger render={ <Button> <PlusIcon className="mr-2 h-4 w-4" /> <Trans i18nKey="bookmarks.actions.createBookmark" /> </Button> } /> <DialogContent> <DialogHeader> <DialogTitle> <Trans i18nKey="bookmarks.createDialog.title" /> </DialogTitle> </DialogHeader> <CreateBookmarkForm onSuccess={() => setOpen(false)} /> </DialogContent> </Dialog> );}It also added the onSuccess prop to the CreateBookmarkForm component to close the dialog after a successful submission.
The form now works, and new bookmarks show up in the list.
Summarizing the Best Practices of using Cursor with MakerKit
Okay, we've finished this little tutorial. Here's what we've achieved with Cursor, in a little more than a few minutes:
- Database Schema: we've learned how to effectively prompt AI for schema design
- Building Pages: we've learned how to build a page with Cursor
- Data Fetching: we've learned how to fetch data from Supabase using Cursor
- Building Forms: we've learned how to build a form with Cursor
- Improving Code: we've learned how to improve the code using Cursor
- Fixing Bugs: we've learned how to fix bugs using Cursor
1. One task at a time
Split the work into small tasks. Each prompt should ask for one change, and the next prompt builds on the verified result of the previous one.
2. Use Context files
The most important thing to remember is to use context files. Context files are files that contain information about the project, such as the database schema, the UI components, and the translations.
By helping Cursor with the correct context files, you will reduce the amount of hallucinations and errors Cursor makes.
3. Discover, Action, and Improve
When having Cursor build a task for you, this is the strategy that works for me:
- Discover: ask Cursor to discover the context for you (unless you know this already)
- Action: after providing the context, ask Cursor to perform the action. Refine the prompt to ask Cursor to perform the action in a more specific way, and only one at a time.
- Improve: after the action is performed, ask Cursor to improve the code based on the feedback it provides. Fix bugs, improve the code, and so on.
Every feature in this tutorial was built with these three steps.
4. Avoid Context Pollution
Sometimes, remember to restart new chat sessions with Cursor to avoid context pollution. This is probably a valid point for any AI tool.
5. Update the rules after a manual edit
After making a manual edit to AGENTS.md or a .cursor/rules/*.mdc file, commit it. Cursor reloads rules on the next prompt, and your teammates get the same context for free.
6. Use the MCP server for grounded answers
The MakerKit MCP server lets Cursor read your schema files and migrations, create migrations, manage translations, and look up the kit's UI components. Once it is connected, prompts like "add a tags table that mirrors how bookmarks is set up" produce migrations based on the real schema files instead of a guess.
What Changed in 2026
If you used this guide when it was originally published in early 2025, here's what's new:
AGENTS.mdreplaces.cursorrulesas the maintained convention. Cursor reads the root file and nested files in subdirectories, and the same files are read by Claude Code, Codex, Google Antigravity, and Devin Desktop.- Agent mode handles end-to-end feature work across several files and terminal commands, not just snippet generation. Tab still handles inline next-edit predictions while you type.
- MCP servers give the agent tools for your schema, migrations, and translations instead of static rules. Skills and hooks are also available on paid plans.
- Cloud agents (previously called Background Agents) run tasks on remote machines, which is useful for long-running migrations or test runs while you keep coding. Claude Code's counterpart is scheduled routines, which run unattended on a clock, an API call, or a GitHub event.
- Bugbot reviews pull requests for bugs and security issues on GitHub, GitLab, Bitbucket, and Azure DevOps, with usage-based billing.
- Pricing: Pro starts at $20/month, Pro+ and Ultra add more agent usage for heavy users, and Teams costs $40/user/month. Check cursor.com/pricing for current limits.
- Next.js 16 makes
paramsa Promise that you await, and the code samples are updated accordingly. - Windsurf is now Devin Desktop, after Cognition renamed it in June 2026.
For how Cursor compares with Claude Code, Codex, Antigravity, and app builders like Lovable, see the best vibe coding tools for SaaS.
Tested with the MakerKit Next.js Supabase kit (Next.js 16.4, React 19.3), October 2026.
Frequently Asked Questions
Can you build a SaaS with Cursor?
Does Cursor read AGENTS.md?
How do I add the MakerKit MCP server to Cursor?
Cursor vs Claude Code: which is better for SaaS development?
What does Cursor cost?
What happened to Windsurf?
About MakerKit
MakerKit is a full-stack SaaS template that gives you a foundation to build ambitious B2B SaaS applications. It ships AGENTS.md, CLAUDE.md, skills, and an MCP server, so you can pair it with Cursor, Claude Code, or Devin Desktop (formerly Windsurf) to compress a six-month build into a few weeks of focused work.