diff --git a/README.md b/README.md
index 6ed0853..89390df 100644
--- a/README.md
+++ b/README.md
@@ -117,7 +117,7 @@ const webRules = getRulesForPlatform('web');
const backendRules = getRulesForPlatform('backend');
```
-## Available Rules (45 total)
+## Available Rules (50 total)
### Expo Router Rules
@@ -149,6 +149,13 @@ const backendRules = getRulesForPlatform('backend');
| `glass-interactive-prop` | warning | expo | GlassView in pressables needs `isInteractive={true}` |
| `glass-no-opacity-animation` | warning | expo | No opacity animations on GlassView |
+### Next.js Rules
+
+| Rule | Severity | Platform | Description |
+| ---------------------------- | -------- | -------- | ----------------------------------------------------------------- |
+| `require-use-client` | error | web | Files using client-only features must have "use client" directive |
+| `no-server-import-in-client` | error | web | "use client" files must not import server-only modules |
+
### React / JSX Rules
| Rule | Severity | Platform | Description |
@@ -320,6 +327,49 @@ When using NativeTabs from expo-router/unstable-native-tabs, each screen needs 6
```
+### `require-use-client`
+
+```tsx
+// Bad - uses hooks without "use client"
+import { useState } from 'react';
+
+export function Counter() {
+ const [count, setCount] = useState(0);
+ return setCount(count + 1)}>{count} ;
+}
+
+// Good - has "use client" directive
+('use client');
+
+import { useState } from 'react';
+
+export function Counter() {
+ const [count, setCount] = useState(0);
+ return setCount(count + 1)}>{count} ;
+}
+```
+
+### `no-server-import-in-client`
+
+```tsx
+// Bad - server-only module in client file
+'use client';
+
+import { cookies } from 'next/headers';
+
+export function UserMenu() {
+ return
Menu
;
+}
+
+// Good - use server-only modules only in server components
+import { cookies } from 'next/headers';
+
+export function UserMenu() {
+ const session = cookies().get('session');
+ return Menu
;
+}
+```
+
### `no-complex-jsx-expressions`
```jsx
diff --git a/src/rules/index.ts b/src/rules/index.ts
index 1f89a10..dd01b2a 100644
--- a/src/rules/index.ts
+++ b/src/rules/index.ts
@@ -47,6 +47,8 @@ import { noManualRetryLoop } from './no-manual-retry-loop';
import { noEmojiIcons } from './no-emoji-icons';
import { noSyncFs } from './no-sync-fs';
import { preferNamedParams } from './prefer-named-params';
+import { requireUseClient } from './require-use-client';
+import { noServerImportInClient } from './no-server-import-in-client';
export const rules: Record = {
'no-relative-paths': noRelativePaths,
@@ -97,4 +99,6 @@ export const rules: Record = {
'no-emoji-icons': noEmojiIcons,
'no-sync-fs': noSyncFs,
'prefer-named-params': preferNamedParams,
+ 'require-use-client': requireUseClient,
+ 'no-server-import-in-client': noServerImportInClient,
};
diff --git a/src/rules/meta.ts b/src/rules/meta.ts
index c93fc7e..ef0a579 100644
--- a/src/rules/meta.ts
+++ b/src/rules/meta.ts
@@ -30,6 +30,8 @@ export const rulePlatforms: Partial> = {
'no-inline-script-code': ['web'],
'browser-api-in-useeffect': ['web'],
'no-tailwind-animation-classes': ['web'],
+ 'require-use-client': ['web'],
+ 'no-server-import-in-client': ['web'],
// Expo + Web (shared frontend)
'no-relative-paths': ['expo', 'web'],
diff --git a/src/rules/no-server-import-in-client.ts b/src/rules/no-server-import-in-client.ts
new file mode 100644
index 0000000..bc438d9
--- /dev/null
+++ b/src/rules/no-server-import-in-client.ts
@@ -0,0 +1,77 @@
+import traverse from '@babel/traverse';
+import type { File } from '@babel/types';
+import type { LintResult } from '../types';
+
+const RULE_NAME = 'no-server-import-in-client';
+
+const SERVER_ONLY_MODULES = ['server-only', 'next/headers'];
+
+function isServerOnlyModule(source: string): boolean {
+ return SERVER_ONLY_MODULES.some((mod) => source === mod);
+}
+
+export function noServerImportInClient(ast: File, _code: string): LintResult[] {
+ const hasUseClient = ast.program.directives.some((d) => d.value.value === 'use client');
+
+ if (!hasUseClient) {
+ return [];
+ }
+
+ const results: LintResult[] = [];
+
+ traverse(ast, {
+ ImportDeclaration(path) {
+ // Skip type-only imports (erased at compile time)
+ if (path.node.importKind === 'type') return;
+
+ const source = path.node.source.value;
+ const { loc } = path.node;
+
+ if (isServerOnlyModule(source)) {
+ results.push({
+ rule: RULE_NAME,
+ message: `'${source}' is a server-only module and cannot be imported in a "use client" file.`,
+ line: loc?.start.line ?? 0,
+ column: loc?.start.column ?? 0,
+ severity: 'error',
+ });
+ }
+ },
+
+ ExportNamedDeclaration(path) {
+ const { source, loc } = path.node;
+ if (!source) return;
+
+ if (path.node.exportKind === 'type') return;
+
+ if (isServerOnlyModule(source.value)) {
+ results.push({
+ rule: RULE_NAME,
+ message: `'${source.value}' is a server-only module and cannot be re-exported from a "use client" file.`,
+ line: loc?.start.line ?? 0,
+ column: loc?.start.column ?? 0,
+ severity: 'error',
+ });
+ }
+ },
+
+ ExportAllDeclaration(path) {
+ const source = path.node.source.value;
+ const { loc } = path.node;
+
+ if (path.node.exportKind === 'type') return;
+
+ if (isServerOnlyModule(source)) {
+ results.push({
+ rule: RULE_NAME,
+ message: `'${source}' is a server-only module and cannot be re-exported from a "use client" file.`,
+ line: loc?.start.line ?? 0,
+ column: loc?.start.column ?? 0,
+ severity: 'error',
+ });
+ }
+ },
+ });
+
+ return results;
+}
diff --git a/src/rules/require-use-client.ts b/src/rules/require-use-client.ts
new file mode 100644
index 0000000..338188c
--- /dev/null
+++ b/src/rules/require-use-client.ts
@@ -0,0 +1,79 @@
+import traverse from '@babel/traverse';
+import type { File } from '@babel/types';
+import type { LintResult } from '../types';
+
+const RULE_NAME = 'require-use-client';
+
+export function requireUseClient(ast: File, _code: string): LintResult[] {
+ const hasDirective = ast.program.directives.some(
+ (d) => d.value.value === 'use client' || d.value.value === 'use server',
+ );
+
+ if (hasDirective) {
+ return [];
+ }
+
+ const results: LintResult[] = [];
+
+ traverse(ast, {
+ CallExpression(path) {
+ const { callee, loc } = path.node;
+
+ // React hooks: useState(), useEffect(), useRef(), etc.
+ if (callee.type === 'Identifier' && /^use[A-Z]/.test(callee.name)) {
+ results.push({
+ rule: RULE_NAME,
+ message: `'${callee.name}()' is a client-only hook. Add "use client" at the top of this file.`,
+ line: loc?.start.line ?? 0,
+ column: loc?.start.column ?? 0,
+ severity: 'error',
+ });
+ }
+
+ // createContext()
+ if (callee.type === 'Identifier' && callee.name === 'createContext') {
+ results.push({
+ rule: RULE_NAME,
+ message: `'createContext()' is client-only. Add "use client" at the top of this file.`,
+ line: loc?.start.line ?? 0,
+ column: loc?.start.column ?? 0,
+ severity: 'error',
+ });
+ }
+
+ // React.createContext()
+ if (
+ callee.type === 'MemberExpression' &&
+ callee.object.type === 'Identifier' &&
+ callee.object.name === 'React' &&
+ callee.property.type === 'Identifier' &&
+ callee.property.name === 'createContext'
+ ) {
+ results.push({
+ rule: RULE_NAME,
+ message: `'React.createContext()' is client-only. Add "use client" at the top of this file.`,
+ line: loc?.start.line ?? 0,
+ column: loc?.start.column ?? 0,
+ severity: 'error',
+ });
+ }
+ },
+
+ JSXAttribute(path) {
+ const { name, loc } = path.node;
+
+ // Event handler props: onClick, onChange, onSubmit, etc.
+ if (name.type === 'JSXIdentifier' && /^on[A-Z]/.test(name.name)) {
+ results.push({
+ rule: RULE_NAME,
+ message: `'${name.name}' is a client-only event handler. Add "use client" at the top of this file.`,
+ line: loc?.start.line ?? 0,
+ column: loc?.start.column ?? 0,
+ severity: 'error',
+ });
+ }
+ },
+ });
+
+ return results;
+}
diff --git a/tests/config-modes.test.ts b/tests/config-modes.test.ts
index 9a3e1ea..112bd5e 100644
--- a/tests/config-modes.test.ts
+++ b/tests/config-modes.test.ts
@@ -123,7 +123,7 @@ describe('config modes', () => {
expect(ruleNames).toContain('no-relative-paths');
expect(ruleNames).toContain('expo-image-import');
expect(ruleNames).toContain('no-stylesheet-create');
- expect(ruleNames.length).toBe(48);
+ expect(ruleNames.length).toBe(50);
});
});
});
diff --git a/tests/no-server-import-in-client.test.ts b/tests/no-server-import-in-client.test.ts
new file mode 100644
index 0000000..7003810
--- /dev/null
+++ b/tests/no-server-import-in-client.test.ts
@@ -0,0 +1,106 @@
+import { describe, it, expect } from 'vitest';
+import { lintJsxCode } from '../src';
+
+const config = { rules: ['no-server-import-in-client'] };
+
+describe('no-server-import-in-client rule', () => {
+ it('should flag server-only import in "use client" file', () => {
+ const code = `
+ "use client";
+ import 'server-only';
+ export function Component() {
+ return
;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(1);
+ expect(results[0].rule).toBe('no-server-import-in-client');
+ expect(results[0].message).toContain('server-only');
+ expect(results[0].severity).toBe('error');
+ });
+
+ it('should flag next/headers import in "use client" file', () => {
+ const code = `
+ "use client";
+ import { headers } from 'next/headers';
+ export function Component() {
+ return
;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(1);
+ expect(results[0].message).toContain('next/headers');
+ });
+
+ it('should flag multiple server-only imports', () => {
+ const code = `
+ "use client";
+ import 'server-only';
+ import { headers } from 'next/headers';
+ export function Component() {
+ return
;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(2);
+ });
+
+ it('should flag re-exports from server-only modules', () => {
+ const code = `
+ "use client";
+ export { headers } from 'next/headers';
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(1);
+ expect(results[0].message).toContain('re-exported');
+ });
+
+ it('should allow server-only imports without "use client" directive', () => {
+ const code = `
+ import 'server-only';
+ import { headers } from 'next/headers';
+ export function ServerComponent() {
+ return
;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(0);
+ });
+
+ it('should allow server-only imports in "use server" files', () => {
+ const code = `
+ "use server";
+ import { headers } from 'next/headers';
+ export async function getHeaders() {
+ return headers();
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(0);
+ });
+
+ it('should allow type-only imports from server modules', () => {
+ const code = `
+ "use client";
+ import type { HeadersFunction } from 'next/headers';
+ export function Component() {
+ return
;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(0);
+ });
+
+ it('should allow normal imports in "use client" files', () => {
+ const code = `
+ "use client";
+ import { useState } from 'react';
+ import Link from 'next/link';
+ export function Component() {
+ return Home;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(0);
+ });
+});
diff --git a/tests/require-use-client.test.ts b/tests/require-use-client.test.ts
new file mode 100644
index 0000000..1c31d74
--- /dev/null
+++ b/tests/require-use-client.test.ts
@@ -0,0 +1,135 @@
+import { describe, it, expect } from 'vitest';
+import { lintJsxCode } from '../src';
+
+const config = { rules: ['require-use-client'] };
+
+describe('require-use-client rule', () => {
+ it('should flag useState without directive', () => {
+ const code = `
+ import { useState } from 'react';
+ function Counter() {
+ const [count, setCount] = useState(0);
+ return {count}
;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(1);
+ expect(results[0].rule).toBe('require-use-client');
+ expect(results[0].message).toContain('useState()');
+ expect(results[0].severity).toBe('error');
+ });
+
+ it('should flag useEffect without directive', () => {
+ const code = `
+ import { useEffect } from 'react';
+ function Component() {
+ useEffect(() => {}, []);
+ return
;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(1);
+ expect(results[0].message).toContain('useEffect()');
+ });
+
+ it('should flag onClick event handler without directive', () => {
+ const code = `
+ function Button() {
+ return alert('hi')}>Click ;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(1);
+ expect(results[0].message).toContain('onClick');
+ });
+
+ it('should flag multiple event handlers', () => {
+ const code = `
+ function Form() {
+ return (
+
+ );
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(2);
+ });
+
+ it('should flag createContext without directive', () => {
+ const code = `
+ import { createContext } from 'react';
+ const MyContext = createContext(null);
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(1);
+ expect(results[0].message).toContain('createContext()');
+ });
+
+ it('should flag React.createContext without directive', () => {
+ const code = `
+ import React from 'react';
+ const MyContext = React.createContext(null);
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(1);
+ expect(results[0].message).toContain('React.createContext()');
+ });
+
+ it('should flag multiple client-only features', () => {
+ const code = `
+ import { useState } from 'react';
+ function Counter() {
+ const [count, setCount] = useState(0);
+ return setCount(count + 1)}>{count} ;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(2);
+ });
+
+ it('should allow code with "use client" directive', () => {
+ const code = `
+ "use client";
+ import { useState } from 'react';
+ function Counter() {
+ const [count, setCount] = useState(0);
+ return setCount(count + 1)}>{count} ;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(0);
+ });
+
+ it('should allow code with "use server" directive', () => {
+ const code = `
+ "use server";
+ export async function submitForm() {
+ const result = useFormState();
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(0);
+ });
+
+ it('should allow code with no client-only features', () => {
+ const code = `
+ export function ServerComponent() {
+ return Hello World
;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(0);
+ });
+
+ it('should not flag custom hook definitions (only calls)', () => {
+ const code = `
+ export function useCustomHook() {
+ return 42;
+ }
+ `;
+ const results = lintJsxCode(code, config);
+ expect(results).toHaveLength(0);
+ });
+});