With the release of the Cursor rules for Makerkit, we're excited to announce that we're now offering enhanced Cursor support for Makerkit projects. 🚀
In this post, I want to show you the best practices to build a SaaS with Cursor and Makerkit. Cursor is a powerful AI Editor that helps developers build faster thanks to an extremely intelligent AI and unparalleled UX.
Many of Makerkit's customers have started using Cursor to build their SaaS. As such, this pushed me to optimize a set of rules to improve Cursor's ability to work within a Makerkit project, and to allow me to understand where Cursor is lacking so that we can improve it.
While Cursor does an extremely good job at generating and understanding code, remember that the driver is us. We need to be able to ask the right questions, and most importantly, understand the answers and when it's misleading into wrong solutions.
Leveraging Cursor's capabilities and understanding its shortcomings will help us build a better SaaS faster.
Using Windsurf instead? Check out Windsurf's version of this post.
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 Windsurf 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.
Additionally, I will make as few manual changes as possible to the codebase.
What You'll Learn 🎯
- 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
Makerkit provides a set of rules that you can use to optimize Cursor's ability to work within a Makerkit project. These are store in the .cursorrules
file at the root of your project, and help you to understand:
- Project structure and conventions
- Common patterns and components
- Import paths and aliases
- Type definitions and schemas
These rules are in continuous development, and will be updated as we add new features to Makerkit.
💡 Pro Tip: Keep your .cursorrules
file updated with your SaaS business logic. This helps Cursor suggest the best possible solutions.
👉 Learn more about the Cursor rules 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. Let me break down the project organization and explain where to add new features.
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 above is quite an excellent response, and I'm glad that Cursor was able to help me understand the project structure and where to add new features.
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!
Use context files to improve the response
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.ts
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 manually update the sidebar for me using Composer.
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
Learning Goals 🎯:
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 missed the mark by suggesting tables Makerkit already provides (like users and accounts) - showing 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.users
table, and instead use thepublic.accounts
table - Permissions: You want to use
public.has_role_on_account
to 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.accounts
topublic.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 a DB schema, always provide the main migration file as context to the AI.
Result of the Refined Prompt
Cursor's output really shined here! Let's look at what it got right:
- ✅ Account Ownership: Correctly linked tables to
public.accounts
- ✅ Access Control: Used public.has_role_on_account for permissions
- ✅ Security: Created proper RLS policies
- ✅ Automation: Added helpful 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?
As 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.
It is extremely important to 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 did very well!
- ✅ The page was correctly created under the team workspace, at
app/home/ [account]/bookmarks/page.tsx
and following Next.js App Router conventions - ✅ The page was created with the correct metadata
- 🚫 Unfortunately, the page was not created with the correct layout like 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!
Data 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 an excellent overall snippet of code (however, still simple):
- Import the
getSupabaseServerClient
function from the@kit/supabase/server-client
package - Use the
from
method to select the data from thebookmarks
table - Use the
order
method to sort the data in descending order - Use the
select
method 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
account
parameter is passed to the page, and it is used to filter the data - We use a join to match the slug using the
account_id
column
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 { withI18n } from '~/lib/i18n/with-i18n';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: { account: string } }) { const client = getSupabaseServerClient(); const { data: bookmarks, error } = await client .from('bookmarks') .select( ` *, accounts!bookmarks_account_id_fkey(slug) `, ) .eq('accounts.slug', params.account); if (error) { console.error('Error fetching bookmarks:', error); return notFound(); } return ( <> <TeamAccountLayoutPageHeader account={params.account} title={<Trans i18nKey={'common:routes.bookmarks'} />} description={<AppBreadcrumbs />} /> <PageBody> <div className="container mx-auto"> <BookmarksList bookmarks={bookmarks} /> </div> </PageBody> </> );}export default withI18n(BookmarksPage);
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:
The 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 them to follow Makerkit's conventions and create a service in _lib/server/bookmarks/bookmarks.service.ts
to fetch the data and use the service in the Server Component to fetch the data and pass it to the Client Component.
This will help us keep the code clean and maintainable.
Not necessary, but helps in the long run.
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 { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';import { Database } from '@kit/supabase/database';type Bookmark = Database['public']['Tables']['bookmarks']['Row'];type BookmarksListProps = { bookmarks: Bookmark[];};export default function BookmarksList({ bookmarks }: BookmarksListProps) { const { account } = useTeamAccountWorkspace(); 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/home/[account]/bookmarks/page.tsx
and following Next.js App Router conventions - Metadata: the page was created with the correct metadata
- Bookmarks Data: the page fetched the data from the
bookmarks
table and displayed it in the UI - Filtering Data: the page filtered the data by the selected account ID
- List Component: the page used the
BookmarksList
component 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
Table
component 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.
Let'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 asChild> <Button> <PlusIcon className="mr-2 h-4 w-4" /> <Trans i18nKey="bookmarks:actions.createBookmark" /> </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle> <Trans i18nKey="bookmarks:createDialog.title" /> </DialogTitle> </DialogHeader> <CreateBookmarkForm /> </DialogContent> </Dialog> );}
And the form:
'use client';import { useTransition } from 'react';import { zodResolver } from '@hookform/resolvers/zod';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 { Trans } from '@kit/ui/trans';import { CreateBookmarkSchema } from '../_lib/schema/create-bookmark.schema';import { createBookmarkAction } from '../_lib/server/server-actions';export function CreateBookmarkForm() { const [isPending, startTransition] = useTransition(); const { account } = useTeamAccountWorkspace(); const form = useForm({ resolver: zodResolver(CreateBookmarkSchema), defaultValues: { url: '', title: '', description: '', accountId: account.id, }, }); const onSubmit = form.handleSubmit((data) => { startTransition(async () => { try { await createBookmarkAction(data); form.reset({ url: '', title: '', description: '', accountId: account.id, }); } catch (e) { console.error(e); } }); }); 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>;
And created a Server Action to handle the form submission:
'use server';import { revalidatePath } from 'next/cache';import { enhanceAction } from '@kit/next/actions';import { getSupabaseServerClient } from '@kit/supabase/server-client';import { CreateBookmarkSchema } from '../schema/create-bookmark.schema';export const createBookmarkAction = enhanceAction( async function (data) { const client = getSupabaseServerClient(); const { error } = await client.from('bookmarks').insert({ url: data.url, title: data.title, description: data.description, account_id: data.accountId, }); if (error) { throw error; } revalidatePath('/home/[account]/bookmarks', 'page'); return { success: true, }; }, { schema: CreateBookmarkSchema, },);
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.
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:
How to close the dialog after a successful submission?
- In the first attempt, I did not provide the
dialog.tsx
file 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.tsx
file 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 asChild> <Button> <PlusIcon className="mr-2 h-4 w-4" /> <Trans i18nKey="bookmarks:actions.createBookmark" /> </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle> <Trans i18nKey="bookmarks:createDialog.title" /> </DialogTitle> </DialogHeader> <CreateBookmarkForm onSuccess={() => setOpen(false)} /> </DialogContent> </Dialog> );}
Additionally, it added the onSuccess
prop to the CreateBookmarkForm
component to close the dialog after a successful submission.
Our form is now successfully working, and we can now display bookmarks in the UI. Yay! 🎉
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
One thing that is quite important to remember is that isolating the task at hand in small chunks is the key to success when working with AI. Try to focus your prompts on achieving small tasks at a time, and then gradually build on top of them.
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 allucinations and errors Cursor will (quite inevitably) make.
3. Discover, Action, and Improve
When having Cursor build a task for you, this is the strategy I found quite successful:
- 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.
This approach has proven to be quite effective in achieving the desired result.
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 the rules, you can update the rules to drive Cursor to generate the correct output.
About Makerkit, the best Next.js SaaS Boilerplate
Makerkit is a full-stack SaaS template that gives you a foundation to build ambitious B2B SaaS applications.