-
-
Notifications
You must be signed in to change notification settings - Fork 6.5k
feat(mdx): move to next-mdx-remote
#8490
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
👋 Codeowner Review RequestThe following codeowners have been identified for the changed files: Team reviewers: @nodejs/web-infra @nodejs/nodejs-website Please review the changes when you have a chance. Thank you! 🙏 |
9551c61 to
e64f5af
Compare
|
I'm aware reviewing this many files is impossible. Once I get everything passing, I will make this much more manageable. Right now, the goal was just to be in a position where a feature branch could be opened |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #8490 +/- ##
==========================================
- Coverage 74.21% 74.16% -0.05%
==========================================
Files 106 105 -1
Lines 9102 9038 -64
Branches 308 303 -5
==========================================
- Hits 6755 6703 -52
+ Misses 2345 2333 -12
Partials 2 2 ☔ View full report in Codecov by Sentry. |
|
casual tuesday 1am (cest): claudio about to chill with YouTube |
|
I think you might wanna go back to that YouTube... this is no where near ready 😅. On the silver lining, through the process of building this, I have several bug reports to send to Next.js. |
📦 Build Size ComparisonSummary
Changes➕ Added Assets (1)
➖ Removed Assets (3)
|
😲I'm still figuring out where this is a good thing or a bad thing :/ |
Imma gonna back to another lifetime 😆 -- Im out! HAHAHA |
🌡️ |
604cbc4 to
4f7c07e
Compare
4f7c07e to
8005acf
Compare
|
If I can get it to work, we should see a substantial size reduction on both the client and server build.... if I can get it to work |
8005acf to
d3d2bd4
Compare
next-mdx-remote with a dedicated MDX content package
next-mdx-remote with a dedicated MDX content packagenext-mdx-remote
abfea51 to
aa2ebec
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Next.js seems to really enjoy changing this file, should we .gitignore it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR refactors the Node.js website's MDX handling from a custom implementation to the Next.js-recommended next-mdx-remote library. The change simplifies the codebase by replacing custom MDX compilation logic with a well-supported third-party solution.
Key changes:
- Migrated from custom
@mdx-js/mdxcompilation tonext-mdx-remote/rsc - Removed dependencies on
@vcarl/remark-headings,remark-reading-time,vfile, andvfile-matter(now handled by next-mdx-remote) - Reorganized MDX-related code into a new
router/directory structure with dedicated plugin files - Simplified page rendering logic across locale and blog routes
Reviewed changes
Copilot reviewed 30 out of 32 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Updated dependencies: added next-mdx-remote, moved @mdx-js/mdx to devDependencies, removed custom remark/vfile packages |
| apps/site/package.json | Aligned package changes with lock file |
| apps/site/types/server.ts | Updated type definitions to use inline heading type instead of @vcarl/remark-headings |
| apps/site/types/markdown.ts | Added MarkdownFile type and canonical frontmatter field |
| apps/site/util/array.ts | Added joinNested utility for path segment joining |
| apps/site/router/index.ts | New centralized router logic using next-mdx-remote's compileMDX |
| apps/site/router/render.tsx | Extracted page rendering logic into dedicated component |
| apps/site/router/page.ts | Shared page metadata and viewport generation logic |
| apps/site/router/mdx/plugins/* | Reorganized MDX plugins (table, headings, shiki) into dedicated files |
| apps/site/app/[locale]/page.tsx | Simplified page component using new router infrastructure |
| apps/site/app/[locale]/blog/[cat]/page.tsx | New blog category page using simplified routing |
| apps/site/app/[locale]/download/archive/[version]/page.tsx | Refactored to use new router infrastructure |
| apps/site/next.helpers.mjs | Simplified markdown file retrieval with new CONTENT_ROOT constant |
| apps/site/scripts/* | Updated to use new helper functions and constants |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| markdown.pathname = `/download/archive/${version}`; | ||
|
|
||
| return renderPage(markdown); |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The markdown object returned from getMarkdownFile is being mutated directly. If the markdown file is cached (which it is via the cache wrapper in router/index.ts), this mutation could affect all consumers of the cached value.
Consider creating a new object with the updated pathname instead of mutating the original: return renderPage({ ...markdown, pathname: /download/archive/${version} });
| markdown.pathname = `/download/archive/${version}`; | |
| return renderPage(markdown); | |
| return renderPage({ | |
| ...markdown, | |
| pathname: `/download/archive/${version}`, | |
| }); |
| context: { ...context, pathname: `/download/archive/${pathname}` }, | ||
| }); | ||
| if (isDynamicRoute) { | ||
| const markdown = (await getMarkdownFile(locale, 'download/archive'))!; |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The non-null assertion operator (!) is used here without proper null checking. If getMarkdownFile returns null (e.g., the file doesn't exist), this will throw a runtime error. The assertion should be replaced with proper error handling or null checking before the mutation occurs.
| const markdown = (await getMarkdownFile(locale, 'download/archive'))!; | |
| const markdown = await getMarkdownFile(locale, 'download/archive'); | |
| if (!markdown) { | |
| return notFound(); | |
| } |
| const getPage: FC<PageParams> = async props => { | ||
| const { path, locale: routeLocale } = await props.params; | ||
|
|
||
| // Gets the current full pathname for a given path | ||
| const [locale, pathname] = basePage.getLocaleAndPath(path, routeLocale); | ||
| const { path = [], locale } = await props.params; | ||
|
|
||
| // Gets the Markdown content and context | ||
| const [content, context] = await basePage.getMarkdownContext({ | ||
| locale, | ||
| pathname, | ||
| }); | ||
| const markdown = await getMarkdownFile(locale, joinNested(path)); | ||
|
|
||
| // If we have a filename and layout then we have a page | ||
| if (context.filename && context.frontmatter.layout) { | ||
| return basePage.renderPage({ | ||
| content, | ||
| layout: context.frontmatter.layout, | ||
| context, | ||
| }); | ||
| } | ||
|
|
||
| return notFound(); | ||
| return markdown ? renderPage(markdown) : notFound(); | ||
| }; |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing locale validation and redirect logic. The previous implementation in next.dynamic.page.mjs validated whether the locale was in availableLocaleCodes and redirected to the default locale if it wasn't. This validation and redirect logic is now absent, which could lead to pages being rendered with unsupported locales.
| export const joinNested = ( | ||
| ...args: Array<string | Array<string> | undefined> | ||
| ) => join(...(args.filter(Boolean) as Array<string>).flat()); |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function joinNested has an unclear name that doesn't clearly convey its purpose. The name suggests it joins nested structures, but it actually joins path segments (which may be nested arrays) into a single path string. A more descriptive name would be joinPathSegments or flatJoinPath to better indicate that it flattens and joins path components.
| export const joinNested = ( | |
| ...args: Array<string | Array<string> | undefined> | |
| ) => join(...(args.filter(Boolean) as Array<string>).flat()); | |
| export const joinPathSegments = ( | |
| ...args: Array<string | Array<string> | undefined> | |
| ) => join(...(args.filter(Boolean) as Array<string>).flat()); | |
| /** | |
| * @deprecated Use {@link joinPathSegments} instead. | |
| */ | |
| export const joinNested = ( | |
| ...args: Array<string | Array<string> | undefined> | |
| ) => joinPathSegments(...args); |
| // Join the arguments like a path | ||
| export const joinNested = ( | ||
| ...args: Array<string | Array<string> | undefined> | ||
| ) => join(...(args.filter(Boolean) as Array<string>).flat()); |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The joinNested utility function is placed in util/array.ts, but its functionality is path manipulation, not array manipulation. While it does use array flattening internally, its primary purpose is path joining. This function would be more appropriately located in a path utilities file or a dedicated routing utilities file.
| import { toString } from 'mdast-util-to-string'; | ||
| import { visit } from 'unist-util-visit'; | ||
|
|
||
| /** | ||
| * Remark plugin that adds data-label attributes to table cells (td) | ||
| * based on their corresponding table headers (th). | ||
| */ | ||
| export default () => tree => { | ||
| visit(tree, 'table', table => { | ||
| // Ensure table has at least a header row and one data row | ||
| if (table.children.length < 2) { | ||
| return; | ||
| } | ||
|
|
||
| const [headerRow, ...dataRows] = table.children; | ||
|
|
||
| if (headerRow.children.length <= 1) { | ||
| table.data ??= {}; | ||
|
|
||
| table.data.hProperties = { | ||
| 'data-cards': 'false', | ||
| }; | ||
| } | ||
|
|
||
| // Extract header labels from the first row | ||
| const headerLabels = headerRow.children.map(headerCell => | ||
| toString(headerCell.children) | ||
| ); | ||
|
|
||
| // Assign data-label to each cell in data rows | ||
| dataRows.forEach(row => { | ||
| row.children.forEach((cell, idx) => { | ||
| if (idx > headerLabels.length - 1) { | ||
| return; | ||
| } | ||
| cell.data ??= {}; | ||
|
|
||
| cell.data.hProperties = { | ||
| 'data-label': headerLabels[idx], | ||
| }; | ||
| }); | ||
| }); | ||
| }); | ||
| }; |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The table plugin was moved from util/table.ts to router/mdx/plugins/table.mjs, but its comprehensive test file (util/__tests__/table.test.mjs with 434 lines) was removed without being relocated. The moved plugin code should maintain the same test coverage to ensure the table labeling functionality continues to work correctly.
| import { toString } from 'mdast-util-to-string'; | ||
| import { visit } from 'unist-util-visit'; | ||
|
|
||
| export const headings = root => { | ||
| const list = []; | ||
|
|
||
| visit(root, 'heading', node => { | ||
| list.push({ | ||
| depth: node.depth, | ||
| value: toString(node, { includeImageAlt: false }), | ||
| }); | ||
| }); | ||
|
|
||
| return list; | ||
| }; | ||
|
|
||
| export default ({ output }) => | ||
| tree => { | ||
| output.headings = headings(tree); | ||
| }; |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The headings plugin lacks test coverage. The plugin extracts heading information from markdown, which is critical functionality for table of contents generation. It should have tests to ensure it correctly handles various heading depths, content types, and edge cases.
| * This file extends on the `page.tsx` file, which is the default file that is used to render | ||
| * the entry points for each locale and then also reused within the [...path] route to render the | ||
| * and contains all logic for rendering our dynamic and static routes within the Node.js Website. | ||
| * | ||
| * Note: that each `page.tsx` should have its own `generateStaticParams` to prevent clash of | ||
| * dynamic params, which will lead on static export errors and other sort of issues. |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment incorrectly states this file "extends on the page.tsx file", but this IS the page.tsx file itself. The comment should clarify that this is the main entry point page for locale routes and is reused by the [...path] route via exports.
| * This file extends on the `page.tsx` file, which is the default file that is used to render | |
| * the entry points for each locale and then also reused within the [...path] route to render the | |
| * and contains all logic for rendering our dynamic and static routes within the Node.js Website. | |
| * | |
| * Note: that each `page.tsx` should have its own `generateStaticParams` to prevent clash of | |
| * dynamic params, which will lead on static export errors and other sort of issues. | |
| * This file is the main `page.tsx` entry point used to render the routes for each locale. | |
| * It is also reused by the `[...path]` route via its exports to render both dynamic and | |
| * static routes within the Node.js Website. | |
| * | |
| * Note: each `page.tsx` should have its own `generateStaticParams` to prevent clashes of | |
| * dynamic params, which can lead to static export errors and other related issues. |
| return locales.map((locale: string) => | ||
| allRoutes.map(path => ({ | ||
| locale, | ||
| path: path.split(sep), | ||
| })) | ||
| ); |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The generateStaticParams function returns a nested array structure (array of arrays) instead of a flat array. The map operation on line 42-46 returns an array of arrays where each locale maps to an array of path objects. This should be flattened before being returned.
The return value should be locales.flatMap(...) or locales.map(...).flat() to ensure a flat array of objects is returned.
| file.pathname = `/blog/${cat}`; | ||
|
|
||
| return renderPage(file); |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The markdown object returned from getMarkdownFile is being mutated directly. If the markdown file is cached (which it is via the cache wrapper in router/index.ts), this mutation could affect all consumers of the cached value.
Consider creating a new object with the updated pathname instead of mutating the original: return renderPage({ ...markdown, pathname: '/blog/${cat}' });
| file.pathname = `/blog/${cat}`; | |
| return renderPage(file); | |
| return renderPage({ ...file, pathname: `/blog/${cat}` }); |

This PR changes our custom MDX logic to the Next.js-recommended
next-mdx-remotelogic. While this isn't the first-party@next/mdxpackage, it's an officially recommended solution to reading MDX from files.Before we can use
@next/mdxdirectly, the following should be resolved:a. Support passing additional options to layouts vercel/next.js#87990 is resolved OR
b. Given that
@next/mdxwould require moving to a 1:1app/-> route layout, we could define the layouts aslayout.tsxfiles, rather than via aWithLayoutprovider/[locale]/slug. However, if we move all of our pages into the App Router, this/[locale]/will no longer be dynamic, since all the possible locales will be hard-coded. How do we set up internationalization? Layouts across many locales?