diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs
index d9f04c868..7474c17a5 100644
--- a/crates/oxc_linter/src/rules.rs
+++ b/crates/oxc_linter/src/rules.rs
@@ -271,6 +271,7 @@ mod oxc {
mod nextjs {
pub mod google_font_display;
pub mod google_font_preconnect;
+ pub mod inline_script_id;
}
oxc_macros::declare_all_lint_rules! {
@@ -511,4 +512,5 @@ oxc_macros::declare_all_lint_rules! {
oxc::only_used_in_recursion,
nextjs::google_font_display,
nextjs::google_font_preconnect,
+ nextjs::inline_script_id,
}
diff --git a/crates/oxc_linter/src/rules/nextjs/inline_script_id.rs b/crates/oxc_linter/src/rules/nextjs/inline_script_id.rs
new file mode 100644
index 000000000..6c23dfb2a
--- /dev/null
+++ b/crates/oxc_linter/src/rules/nextjs/inline_script_id.rs
@@ -0,0 +1,250 @@
+use oxc_ast::{
+ ast::{
+ Expression, ImportDeclarationSpecifier, JSXAttributeItem, JSXAttributeName,
+ ModuleDeclaration, ObjectPropertyKind, PropertyKey,
+ },
+ AstKind,
+};
+use oxc_diagnostics::{
+ miette::{self, Diagnostic},
+ thiserror::Error,
+};
+use oxc_macros::declare_oxc_lint;
+use oxc_span::{GetSpan, Span};
+use rustc_hash::FxHashSet;
+
+use crate::{context::LintContext, rule::Rule, AstNode};
+
+#[derive(Debug, Error, Diagnostic)]
+#[error("eslint-plugin-next(inline-script-id): `next/script` components with inline content must specify an `id` attribute.")]
+#[diagnostic(severity(warning), help("See https://nextjs.org/docs/messages/inline-script-id"))]
+struct InlineScriptIdDiagnostic(#[label] pub Span);
+
+#[derive(Debug, Default, Clone)]
+pub struct InlineScriptId;
+
+declare_oxc_lint!(
+ /// ### What it does
+ ///
+ ///
+ /// ### Why is this bad?
+ ///
+ ///
+ /// ### Example
+ /// ```javascript
+ /// ```
+ InlineScriptId,
+ correctness
+);
+
+impl Rule for InlineScriptId {
+ fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
+ let AstKind::ModuleDeclaration(ModuleDeclaration::ImportDeclaration(import_decl)) =
+ node.kind()
+ else {
+ return;
+ };
+
+ if import_decl.source.value.as_str() != "next/script" {
+ return;
+ }
+
+ let Some(import_specifiers) = &import_decl.specifiers else { return };
+
+ // find default import
+ let Some(default_import) = import_specifiers.iter().find_map(|import_specifier| {
+ let ImportDeclarationSpecifier::ImportDefaultSpecifier(import_default_specifier) =
+ import_specifier
+ else {
+ return None;
+ };
+
+ Some(import_default_specifier)
+ }) else {
+ return;
+ };
+
+ 'references_loop: for reference in
+ ctx.semantic().symbol_references(default_import.local.symbol_id.get().unwrap())
+ {
+ let node = ctx.nodes().get_node(reference.node_id());
+
+ let AstKind::JSXElementName(_) = node.kind() else { continue };
+ let parent_node = ctx.nodes().parent_node(node.id()).unwrap();
+ let AstKind::JSXOpeningElement(jsx_opening_element) = parent_node.kind() else {
+ continue;
+ };
+
+ let Some(AstKind::JSXElement(jsx_element)) = ctx.nodes().parent_kind(parent_node.id())
+ else {
+ continue;
+ };
+
+ let mut prop_names_hash_set = FxHashSet::default();
+
+ for prop in &jsx_opening_element.attributes {
+ match prop {
+ JSXAttributeItem::Attribute(attr) => {
+ if let JSXAttributeName::Identifier(ident) = &attr.name {
+ prop_names_hash_set.insert(ident.name.clone());
+ }
+ }
+ JSXAttributeItem::SpreadAttribute(spread_attr) => {
+ if let Expression::ObjectExpression(obj_expr) =
+ spread_attr.argument.without_parenthesized()
+ {
+ for prop in &obj_expr.properties {
+ if let ObjectPropertyKind::ObjectProperty(obj_prop) = prop {
+ if let PropertyKey::Identifier(ident) = &obj_prop.key {
+ prop_names_hash_set.insert(ident.name.clone());
+ }
+ }
+ }
+ } else {
+ continue 'references_loop;
+ }
+ }
+ }
+ }
+
+ if prop_names_hash_set.contains("id") {
+ continue;
+ }
+
+ if jsx_element.children.len() > 0
+ || prop_names_hash_set.contains("dangerouslySetInnerHTML")
+ {
+ ctx.diagnostic(InlineScriptIdDiagnostic(jsx_opening_element.name.span()));
+ }
+ }
+ }
+}
+
+#[test]
+fn test() {
+ use crate::tester::Tester;
+
+ let pass = vec![
+ r#"import Script from 'next/script';
+
+ export default function TestPage() {
+ return (
+
+ )
+ }"#,
+ r#"import Script from 'next/script';
+
+ export default function TestPage() {
+ return (
+
+ )
+ }"#,
+ r#"import Script from 'next/script';
+
+ export default function TestPage() {
+ return (
+
+ )
+ }"#,
+ r#"import MyScript from 'next/script';
+
+ export default function TestPage() {
+ return (
+
+ {`console.log('Hello world');`}
+
+ )
+ }"#,
+ r#"import MyScript from 'next/script';
+
+ export default function TestPage() {
+ return (
+
+ )
+ }"#,
+ r#"import Script from 'next/script';
+
+ export default function TestPage() {
+ return (
+
+ )
+ }"#,
+ r#"import Script from 'next/script';
+
+ export default function TestPage() {
+ return (
+
+ )
+ }"#,
+ r#"import Script from 'next/script';
+ const spread = { strategy: "lazyOnload" }
+ export default function TestPage() {
+ return (
+
+ )
+ }"#,
+ ];
+
+ let fail = vec![
+ r"import Script from 'next/script';
+
+ export default function TestPage() {
+ return (
+
+ )
+ }",
+ r"import Script from 'next/script';
+
+ export default function TestPage() {
+ return (
+
+ )
+ }",
+ r"import MyScript from 'next/script';
+
+ export default function TestPage() {
+ return (
+
+ {`console.log('Hello world');`}
+
+ )
+ }",
+ r"import MyScript from 'next/script';
+
+ export default function TestPage() {
+ return (
+
+ )
+ }",
+ ];
+
+ Tester::new_without_config(InlineScriptId::NAME, pass, fail).test_and_snapshot();
+}
diff --git a/crates/oxc_linter/src/snapshots/inline_script_id.snap b/crates/oxc_linter/src/snapshots/inline_script_id.snap
new file mode 100644
index 000000000..9c267ca61
--- /dev/null
+++ b/crates/oxc_linter/src/snapshots/inline_script_id.snap
@@ -0,0 +1,41 @@
+---
+source: crates/oxc_linter/src/tester.rs
+expression: inline_script_id
+---
+ ⚠ eslint-plugin-next(inline-script-id): `next/script` components with inline content must specify an `id` attribute.
+ ╭─[inline_script_id.tsx:4:1]
+ 4 │ return (
+ 5 │