Skip to content

Snippet management in Cursor splits boilerplate across three layers: VS Code snippet files insert the fixed shell instantly and identically, a scoped project rule carries the conventions the agent must follow, and an anchored agent prompt fills in the codebase-dependent parts. Cursor Tab, which finishes the current line, is not a managed layer. A CI check keeps all three compiling and in step.

You scaffold your fourth data-fetching component this week. The snippet you copied from a colleague still imports the old fetch hook, the agent invents a third way to render the error state, and the reviewer spends the whole review on boilerplate instead of behaviour. Nobody changed the convention on purpose; the snippet, the rule and the code drifted apart one edit at a time.

This page is for developers who write the same shapes every day in Cursor, and for tech leads who own the team’s shared snippets and rules. It assumes you already use project rules; if not, start with project rules in Cursor.

  • A decision table for which layer should produce a given piece of boilerplate.
  • A committed .vscode/*.code-snippets file with conventions that keep prefixes findable.
  • A scoped rule and four copy-paste prompts that turn a snippet shell into finished, convention-following code.
  • Two CI checks: one proves every snippet expands to code that type-checks, the other catches rules that point at deleted files.
  • The failure modes of a snippet library and how to recover from each.

Which layer should produce this boilerplate?

Section titled “Which layer should produce this boilerplate?”

Each layer trades speed and determinism against knowledge of your code. Put each piece of boilerplate in the cheapest layer that can produce it correctly.

LayerWhere it livesSpeedSame output every time?Knows your code?Use it for
VS Code snippet.vscode/*.code-snippetsInstant, no model callYesNo, only the file name and your tab stopsThe typed shell: props interface, export, test skeleton, the four render states
Project rule.cursor/rules/*.mdcApplies to every matching Agent requestNoThrough the files it namesConventions the agent must follow whenever it touches those files
Anchored prompt or Agent SkillThe chat, a saved command, or a skillOne agent runNoYes, it reads the anchor fileMulti-file scaffolds and bodies that depend on real types and hooks
Cursor Tab (not a layer you manage)Built inAs you typeNoRecent edits and nearby codeFinishing the line you are already writing; see Tab autocomplete

Two rules of thumb follow from the table. If the output should never vary, it is a snippet, not a prompt. If the output must match a file that changes over time, it is a rule or a prompt that names that file, not a snippet that copies it.

Cursor is built on VS Code, so it reads the same snippet files. The syntax below is from VS Code’s snippet documentation (microsoft/vscode-docs, checked on 2026-09-26). After you create the file, confirm Cursor picks it up with the Insert Snippet command from the Command Palette.

  1. Run Snippets: Configure Snippets from the Command Palette and choose New Snippets file for ‘<folder>’. VS Code creates a .code-snippets file in the project’s .vscode folder, which you commit so every teammate gets the same snippets.

  2. Add snippets with a scope, a description, and a prefix in the form category-name. Without scope, a project snippet is offered in every language. The category prefix lets you type comp- to filter the list to components. The optional include and exclude properties take glob patterns and narrow a snippet to matching files, so comp-query below appears only under src/components, the same files the rule’s globs cover. Path patterns match the full file path, hence the leading **/. The properties are recent in VS Code, so confirm Cursor honours them: the snippet should not appear in a .tsx file outside src/components.

    {
    "Component with query states": {
    "scope": "typescriptreact",
    "prefix": "comp-query",
    "include": ["**/src/components/**/*.tsx"],
    "description": "Component that renders loading, error, empty and data states",
    "body": [
    "import { EmptyState, ErrorState, Skeleton } from '@/components/ui/states';",
    "import { ${2:useNotifications} } from '@/hooks/queries';",
    "",
    "interface ${TM_FILENAME_BASE}Props {",
    " ${1:userId}: string;",
    "}",
    "",
    "export function ${TM_FILENAME_BASE}({ ${1:userId} }: ${TM_FILENAME_BASE}Props) {",
    " const query = ${2:useNotifications}(${1:userId});",
    "",
    " if (query.isPending) return <Skeleton />;",
    " if (query.isError) return <ErrorState onRetry={query.refetch} />;",
    " if (query.data.length === 0) return <EmptyState>${3:Nothing here yet}</EmptyState>;",
    " return ${4:null};$0",
    "}"
    ]
    },
    "Vitest file": {
    "scope": "typescript,typescriptreact",
    "prefix": "test-file",
    "description": "Vitest file for the module next to it",
    "isFileTemplate": true,
    "body": [
    "import { describe, expect, it } from 'vitest';",
    "",
    "describe('${1:${TM_FILENAME_BASE}}', () => {",
    " it('${2|returns,renders,throws|} ${3:the expected result}', () => {",
    " ${4:expect(true).toBe(false);}$0",
    " });",
    "});"
    ]
    }
    }

    ${TM_FILENAME_BASE} inserts the file name without its extension, so naming the file InvoiceList.tsx names the component. ${2|returns,renders,throws|} offers a choice. $0 is where the cursor ends. A snippet with isFileTemplate appears under Snippets: Fill File with Snippet, which fills a new, empty file. The test snippet’s default assertion fails on purpose, so a skeleton nobody filled in turns the suite red instead of passing. Give every tab stop a default that exists in your codebase, such as a real hook name: the CI check below expands each snippet with its defaults and type-checks the result.

  3. Keep the file small. Snippets earn their place for shapes you type daily; everything else belongs to a rule or a prompt. Delete a snippet when nothing written in the last quarter matches its shape.

Bootstrapping the file by hand from memory produces the generic templates you were trying to get away from. Let the agent read your code instead, then review the result the same way you would review any config change. Add the two files from the verification section below first (scripts/check-snippets.mjs and tsconfig.snippets.json), or drop the last paragraph of the prompt.

The last line matters: naming the source files lets you check the derivation against two real files instead of reading the whole JSON.

Keep the conventions in a scoped rule, not in the snippet

Section titled “Keep the conventions in a scoped rule, not in the snippet”

A snippet cannot know that your team moved from one fetch hook to another. A rule scoped to the same files can, because it names the canonical example the agent reads every time. Create .cursor/rules/components.mdc. The frontmatter keys below (description, globs, alwaysApply) are the format the project rules page documents, and Cursor’s rules documentation (checked 2026-10-12) lists the same three keys. Cursor’s help says rules apply only to Agent (Chat), not to Tab completion, Inline Edit or Bugbot PR reviews (checked 2026-10-12), so the finishing prompt further down attaches the rule file by name.

---
description: Conventions for React components in src/components
globs: src/components/**/*.tsx
alwaysApply: false
---
- New components start from the comp-query snippet in .vscode/project.code-snippets.
- Data comes from the hooks in src/hooks/queries.ts. Never call fetch in a component.
- Render loading, error and empty states with Skeleton, ErrorState and EmptyState from
src/components/ui/states.tsx.
- The reference implementation is src/components/NotificationList.tsx. Match it.
- Every component gets a test file created from the test-file snippet.

The rule and the snippet now point at each other. When the convention changes, you change the snippet, the rule and the reference file in one pull request, and the anchor check below fails if a file the rule names has moved. Rule types and the file format are covered in project rules in Cursor; a team-wide rules library is in custom rules and templates.

The snippet gives you a file that already has the right skeleton. The agent’s job is the part that depends on your code: the real hook, the real types and the real markup.

  1. Create src/components/InvoiceList.tsx, type comp-query and accept the snippet. Tab through the stops: the prop name, the hook name (replace the default useNotifications, which belongs to the reference component, with useInvoices), the empty-state text and the final return value.

  2. With the cursor at $0, open inline edit (the shortcut is listed in Cursor keyboard shortcuts; the workflow is in inline edit), or select the file and send it to the agent. Paste the prompt below.

  3. Review the result against the gates, not line by line: the type check, the lint and the component test.

For a scaffold that spans several files, such as a route, its schema, its service and its tests, a snippet per file is the wrong tool. Give the agent one anchored prompt and save it where the team can reuse it, as described in prompt templates for Cursor’s agent.

If the same scaffold prompt runs every week, package it as an Agent Skill, which Cursor describes as “a portable, version-controlled package that teaches agents how to perform domain-specific tasks” (cursor.com/docs/skills, checked on 2026-08-28). The skill then travels with the repository like the snippet file does.

How do you verify snippet boilerplate without reading every line?

Section titled “How do you verify snippet boilerplate without reading every line?”

Boilerplate is where nobody looks, which is why broken boilerplate spreads. Put two checks in CI so a bad snippet or a stale rule fails the build instead of reaching the next ten files.

Check 1: every snippet parses, has a unique prefix and expands to code that compiles. Save this as scripts/check-snippets.mjs. It replaces each tab stop with its default, writes the result to .snippet-check/, and fails on invalid JSON, a missing scope or description, a prefix outside the category-name form, or a prefix used twice.

// scripts/check-snippets.mjs: lint and expand project snippets. Run: node scripts/check-snippets.mjs
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const DIR = '.vscode';
const OUT = '.snippet-check';
const EXT = { typescript: 'ts', typescriptreact: 'tsx' };
const errors = [];
const seen = new Map();
// Replace every tab stop with its default so the result is plain source code.
function expand(text) {
let prev;
do {
prev = text;
text = text
.replace(/\$\{\d+:([^${}]*)\}/g, '$1') // ${1:Default} -> Default
.replace(/\$\{\d+\|([^,|]*)[^}]*\|\}/g, '$1') // ${1|a,b|} -> a
.replace(/\$\{[A-Z_]+\}/g, 'Example'); // ${TM_FILENAME_BASE} -> Example
} while (text !== prev);
return text.replace(/\$\d+/g, '').replace(/\$[A-Z_]+/g, 'Example').replace(/\\\$/g, '$');
}
rmSync(OUT, { recursive: true, force: true });
mkdirSync(OUT);
for (const file of readdirSync(DIR).filter((f) => f.endsWith('.code-snippets'))) {
let snippets;
try {
snippets = JSON.parse(readFileSync(join(DIR, file), 'utf8'));
} catch (e) {
errors.push(`${file}: not strict JSON (${e.message})`);
continue;
}
for (const [name, s] of Object.entries(snippets)) {
const where = `${file} > ${name}`;
if (!s.prefix || !s.body) {
errors.push(`${where}: needs prefix and body`);
continue;
}
if (!s.scope) errors.push(`${where}: no scope, so it is offered in every language`);
if (!s.description) errors.push(`${where}: no description`);
for (const p of [s.prefix].flat()) {
if (!/^[a-z]+-[a-z0-9-]+$/.test(p)) errors.push(`${where}: prefix "${p}" is not category-name`);
if (seen.has(p)) errors.push(`${where}: prefix "${p}" is also used by ${seen.get(p)}`);
seen.set(p, where);
}
const ext = EXT[String(s.scope ?? '').split(',')[0].trim()];
if (ext) writeFileSync(join(OUT, `${name.replace(/\W+/g, '_')}.${ext}`), expand([s.body].flat().join('\n')));
}
}
if (errors.length) {
console.error(errors.join('\n'));
process.exit(1);
}
console.log(`${seen.size} snippet prefixes OK, expanded bodies in ${OUT}/`);

VS Code accepts comments in snippet files; this check deliberately does not, so the file stays machine-readable. It handles plain variables such as ${TM_FILENAME_BASE} but not variable transforms; keep transforms out of shared snippets or extend expand. Type-check the expanded bodies with a tsconfig.snippets.json next to your tsconfig.json, and add .snippet-check/ to .gitignore:

{ "extends": "./tsconfig.json", "include": [".snippet-check/*.ts", ".snippet-check/*.tsx"] }

Keep the explicit *.ts and *.tsx globs: with a bare ".snippet-check" entry, TypeScript skips the folder because its name starts with a dot and reports “No inputs were found” (checked with TypeScript 5.9.3).

When the type check fails on an expanded snippet, the snippet has drifted from the code: a hook was renamed, a component moved, or a default names something that never existed. Fix the snippet, not the check.

Check 2: every file a rule names still exists. Save this as scripts/check-rule-anchors.sh. It fails when a rule points at a renamed or deleted file, which is the most common way a rule silently stops working.

#!/usr/bin/env sh
# Fails when a Cursor rule names a file that no longer exists.
missing=0
for p in $(grep -ohE '(src|app|lib|tests)/[A-Za-z0-9_./-]*\.[a-z]+' .cursor/rules/*.mdc | sort -u); do
[ -e "$p" ] || { echo "stale anchor in .cursor/rules: $p"; missing=1; }
done
exit $missing

Run both in the CI job that already runs your type check:

- run: node scripts/check-snippets.mjs
- run: npx tsc --noEmit -p tsconfig.snippets.json
- run: sh scripts/check-rule-anchors.sh

Code the agent generates from a snippet shell passes through the same gates as any other change: the tests the prompt asked for, the type check and the lint. What the checks cannot tell you is whether the convention itself is right. That is a human decision, so give it an owner: a CODEOWNERS entry such as /.vscode/*.code-snippets @your-org/frontend-leads and /.cursor/rules/ @your-org/frontend-leads makes the tech lead approve every change to the shared layer. Once a month, or after a refactor, run the drift audit below.

What breaks in a Cursor snippet library, and how to recover

Section titled “What breaks in a Cursor snippet library, and how to recover”

A snippet never appears in the suggestion list. The file is not in the root .vscode folder, the scope names the wrong language identifier (typescriptreact for .tsx, not tsx), or the JSON is invalid. Run node scripts/check-snippets.mjs, then run Insert Snippet in a file of the target language to see what Cursor actually loaded.

Cursor Tab and the snippet fight for the Tab key. Tab accepts Cursor Tab’s inline suggestion, and it can also accept the highlighted item in the suggestion list. Which one wins when both are showing depends on your build and settings; we could not verify it for the Cursor 3.x line on 2026-09-26. Use category-name prefixes that do not look like code, so Cursor Tab rarely predicts on them, and insert from the suggestion list or Insert Snippet when in doubt. Leave VS Code’s editor.tabCompletion at its default (off) unless the team agrees to turn it on.

The agent ignores the snippet’s shape. The rule does not load for that file, or it names the snippet but not a reference file. Check the rule’s globs against the file’s path, and add a concrete reference file to the rule. A rule that says “follow the pattern” without naming the file gives the agent nothing to copy.

Snippet, rule and code disagree. Someone updated one of the three. Run the drift audit prompt, decide which one reflects the current convention, and change the other two in one pull request so the reviewer sees the whole change.

A rule points at a file that moved. The anchor check fails in CI. Point the rule at the file’s new path, or at a better example if the old one was split. If the check is not in CI yet, run sh scripts/check-rule-anchors.sh locally before you merge a refactor.

The library has grown to a hundred snippets and nobody finds anything. Search the repository for each prefix’s generated shape; if nothing written in the last quarter matches its shape, delete the snippet. Keep only shapes the team types weekly, and move the rest into rules or prompts.

Generated boilerplate compiles but misses a state. A type check proves the shape, not the behaviour. Require one test per render state in the prompt, as the component prompt above does, and see protecting the test oracle for keeping the agent from editing those tests to make them pass.

Where to go next with Cursor snippets and scaffolds

Section titled “Where to go next with Cursor snippets and scaffolds”