Cover Image for Syntax Highlighting in React Server Components Using Bright

Syntax Highlighting in React Server Components Using Bright

Lauro Silva
Lauro Silva
Time to read12 min read
Published3mo ago

As I continue exploring Next.js 14 and React Server Components, I noticed that their was no syntax highlighting library that supported react server components. After a quick search, I found Bright and it was exactly what I was looking for! ✨

Bright is a syntax highlighting library that supports React Server Components. This means that it runs on the server, it doesn't impact bundle-size, and it doesn't require any extra configs. You just install it, import it, and use it.

The best part, is that it supports VS Code's syntax highlighting. This means that you can use any of the themes that you are already familiar with. Bright is created by Code Hike, a company that is focused on creating tools that improve the developer experience. Below are my notes on using Bright, and how I modified it to fit my use case. You can check out the documentation on Bright's website.

Installation

Install Bright using npm or yarn.

1pnpm install bright

Usage

Use it from a server component, for example in Next.js app/page.tsx:

app/page.tsx
1import {Code} from 'bright'
2
3export default function Page() {
4 return <Code lang="py">print("hello brightness")</Code>
5}

In my case, I'm using MDX, so I created a separate component to handle the code blocks.

mdx-components.tsx
1let components = {
2 h1: createHeading(1),
3 h2: createHeading(2),
4 h3: createHeading(3),
5 h4: createHeading(4),
6 h5: createHeading(5),
7 h6: createHeading(6),
8 pre: HighlightedCode,
9 a: CustomLink,
10 CustomImage,
11 Callout,
12 ProsCard,
13 ConsCard,
14 Table,
15}
16
17export function CustomMDX(props) {
18 return <MDXRemote {...props} components={{...components, ...(props.components || {})}} />
19}

Customizing Bright

The HighlightedCode component is where I'm using Bright. This might seem like a lot of code, but it's mostly styling and extensions. Below you can find a breakdown of the code.

bright-code-extensions.tsx
1import {ReactNode} from 'react'
2import {themeIcons} from 'seti-icons'
3import {Code as BrightCode} from 'bright'
4import {CopyButton} from './copy-button'
5import React from 'react'
6import theme from './theme.json'
7
8/** @type {import("bright").Extension} */
9export const link = {
10 name: 'link',
11 InlineAnnotation: ({children, query = ''}: {children: ReactNode; query?: string}) => (
12 <a href={query} style={{textDecoration: 'underline'}}>
13 {children}
14 </a>
15 ),
16}
17
18type FocusAnnotation = {
19 ranges: {fromLineNumber: number; toLineNumber: number}[]
20 // include other properties of the object if there are any
21}
22
23/** @type {import("bright").Extension} */
24export const focus = {
25 name: 'focus',
26 MultilineAnnotation: ({children}: {children: ReactNode}) => (
27 <div style={{filter: 'contrast(0.1)'}}>{children}</div>
28 ),
29 beforeHighlight: (props: any, focusAnnotations: any) => {
30 if (focusAnnotations.length === 0) return props
31
32 const lineCount = props.code.split('\n').length
33
34 const ranges = focusAnnotations.flatMap((a: FocusAnnotation) => a.ranges)
35
36 let newRanges = [{fromLineNumber: 1, toLineNumber: lineCount}]
37
38 for (const range of ranges) {
39 const {fromLineNumber, toLineNumber} = range
40 newRanges = newRanges.flatMap((r) => {
41 if (r.fromLineNumber > toLineNumber || r.toLineNumber < fromLineNumber) return [r]
42 if (r.fromLineNumber >= fromLineNumber && r.toLineNumber <= toLineNumber) return []
43 if (r.fromLineNumber < fromLineNumber && r.toLineNumber > toLineNumber)
44 return [
45 {
46 fromLineNumber: r.fromLineNumber,
47 toLineNumber: fromLineNumber - 1,
48 },
49 {
50 fromLineNumber: toLineNumber + 1,
51 toLineNumber: r.toLineNumber,
52 },
53 ]
54 if (r.fromLineNumber < fromLineNumber)
55 return [
56 {
57 fromLineNumber: r.fromLineNumber,
58 toLineNumber: fromLineNumber - 1,
59 },
60 ]
61 if (r.toLineNumber > toLineNumber)
62 return [
63 {
64 fromLineNumber: toLineNumber + 1,
65 toLineNumber: r.toLineNumber,
66 },
67 ]
68 // Default return value
69 return []
70 })
71 }
72
73 const newAnnotations = props.annotations.filter((a: any) => a.name !== 'focus')
74 newAnnotations.push({
75 name: 'focus',
76 ranges: newRanges,
77 })
78 return {...props, annotations: newAnnotations}
79 },
80}
81
82/** @type {import("bright").Extension} */
83export const fileIcons = {
84 name: 'fileIcons',
85 TabContent: MyTab,
86}
87
88/** @type {import("bright").BrightProps["TabContent"]} */
89function MyTab(props: any) {
90 const {title, colors} = props
91
92 const {svg, color} = colors.colorScheme === 'dark' ? getDarkIcon(title) : getLightIcon(title)
93 const __html = svg.replace(/svg/, `svg fill='${color}'`)
94
95 return (
96 <div
97 style={{
98 display: 'flex',
99 alignItems: 'center',
100 height: '1.5em',
101 marginLeft: -8,
102 fontFamily: 'var(--font-geist-mono)',
103 fontSize: '12px',
104 }}
105 >
106 <span
107 dangerouslySetInnerHTML={{__html}}
108 style={{
109 display: 'inline-block',
110 height: '2em',
111 width: '2em',
112 margin: '-0.5em 0',
113 }}
114 />
115 {title}
116 </div>
117 )
118}
119
120// colors from https://github.com/microsoft/vscode/blob/main/extensions/theme-seti/icons/vs-seti-icon-theme.json
121const getDarkIcon = themeIcons({
122 blue: '#519aba',
123 grey: '#4d5a5e',
124 'grey-light': '#6d8086',
125 green: '#8dc149',
126 orange: '#e37933',
127 pink: '#f55385',
128 purple: '#a074c4',
129 red: '#cc3e44',
130 white: '#d4d7d6',
131 yellow: '#cbcb41',
132 ignore: '#41535b',
133})
134
135const getLightIcon = themeIcons({
136 blue: '#498ba7',
137 grey: '#455155',
138 'grey-light': '#627379',
139 green: '#7fae42',
140 orange: '#cc6d2e',
141 pink: '#dd4b78',
142 purple: '#9068b0',
143 red: '#b8383d',
144 white: '#bfc2c1',
145 yellow: '#b7b73b',
146 ignore: '#3b4b52',
147})
148
149/** @type {import("bright").Extension} */
150export const extractTitleAndTrimCode = {
151 name: 'extractTitleAndTrimCode',
152 beforeHighlight: (props: any) => {
153 const lines = props.code.split('\n')
154 for (let i = 0; i < lines.length; i++) {
155 const titleMatch = lines[i].match(/\/\*(.*)\*\//)
156 if (titleMatch) {
157 const title = titleMatch[1].trim()
158 const newCode = lines.slice(1).join('\n') // remove the first line
159 props.style.background = 'none' // remove the background
160 return {...props, title, code: newCode}
161 }
162 }
163 return props
164 },
165}
166
167function getTextContent(children: React.ReactNode): string {
168 let text = ''
169 React.Children.forEach(children, (child) => {
170 if (typeof child === 'string') {
171 text += child
172 } else if (React.isValidElement(child) && typeof child.props.children === 'string') {
173 text += child.props.children
174 }
175 })
176 return text
177}
178
179export const HighlightedCode: React.FC<CodeProps> = ({children, language = 'javascript'}) => {
180 const code = getTextContent(children)
181
182 return (
183 <div className="relative">
184 <CopyButton text={code} />
185 <BrightCode
186 lang={language || 'javascript'}
187 theme={theme}
188 lineNumbers
189 extensions={[fileIcons, extractTitleAndTrimCode, focus, link]}
190 style={
191 {
192 color: '#71717a',
193 borderRadius: '4px',
194 background: '#011627',
195 overflow: 'hidden',
196 margin: '1em 0',
197 marginTop: '1em',
198 '--selection-background': '#4a20ffcc',
199 '--line-number-color': '#2e3e4d',
200 '--tab-border': '#2e3e4d',
201 '--tab-background': '#011627',
202 '--tab-color': '#708799',
203 '--inactive-tab-background': '#011627',
204 '--inactive-tab-color': '#8b949e',
205 '--tab-top-border': 'none',
206 '--tab-bottom-border': 'none',
207 '--tab-left-border': '#2e3e4d',
208 borderTopLeftRadius: '5px',
209 borderTopRightRadius: '5px',
210 border: '1px solid #2e3e4d',
211 colorScheme: 'dark',
212 fontFamily: 'var(--font-geist-mono)',
213 } as React.CSSProperties
214 }
215 >
216 {children}
217 </BrightCode>
218 </div>
219 )
220}
221
222type CodeProps = {
223 children?: React.ReactNode
224 language?: string
225 value: {
226 language?: string
227 code: string
228 highlightedLines: (number | any)[]
229 title: string
230 }
231}
232
233type TitleProps = {
234 title: string
235 colors: {
236 foreground: string
237 }
238}

The most important part is the BrightCode component. This is where we are using Bright. I'm passing in extensions, which are Bright's way of adding functionality to the code blocks. I'm using the fileIcons extension to add icons to the tabs, the extractTitleAndTrimCode extension to extract the title from the code block, and the focus extension to add a filter to the code block.

bright-code-extensions.tsx
1export const HighlightedCode: React.FC<CodeProps> = ({children, language = 'javascript'}) => {
2 const code = getTextContent(children)
3
4 return (
5 <div className="relative">
6 <CopyButton text={code} />
7 <BrightCode
8 lang={language || 'javascript'}
9 theme={theme}
10 lineNumbers
11 extensions={[fileIcons, extractTitleAndTrimCode, focus, link]}
12 style={
13 {
14 color: '#71717a',
15 borderRadius: '4px',
16 background: '#011627',
17 overflow: 'hidden',
18 margin: '1em 0',
19 marginTop: '1em',
20 '--selection-background': '#4a20ffcc',
21 '--line-number-color': '#2e3e4d',
22 '--tab-border': '#2e3e4d',
23 '--tab-background': '#011627',
24 '--tab-color': '#708799',
25 '--inactive-tab-background': '#011627',
26 '--inactive-tab-color': '#8b949e',
27 '--tab-top-border': 'none',
28 '--tab-bottom-border': 'none',
29 '--tab-left-border': '#2e3e4d',
30 borderTopLeftRadius: '5px',
31 borderTopRightRadius: '5px',
32 border: '1px solid #2e3e4d',
33 colorScheme: 'dark',
34 fontFamily: 'var(--font-geist-mono)',
35 } as React.CSSProperties
36 }
37 >
38 {children}
39 </BrightCode>
40 </div>
41 )
42}

There's several extensions or recipes that you can use, you can find them in the Bright documentation. Check them out, they are pretty cool. I modified mine to fit my use case.

Copy Code Button

I also added a copy code button to the code blocks. This is a simple component that uses the Clipboard API to copy the code to the clipboard.

copy-button.tsx
1'use client'
2
3import {useState, FC} from 'react'
4
5interface CopyButtonProps {
6 text: string
7}
8
9export const CopyButton: FC<CopyButtonProps> = ({text}) => {
10 const [isCopied, setIsCopied] = useState(false)
11
12 const copy = async () => {
13 await navigator.clipboard.writeText(text)
14 setIsCopied(true)
15
16 setTimeout(() => {
17 setIsCopied(false)
18 }, 10000)
19 }
20
21 return (
22 <button
23 className="absolute right-2 top-2 z-10 p-0 font-mono text-xs text-[#708799]"
24 disabled={isCopied}
25 onClick={copy}
26 >
27 {isCopied ? 'Copied! 🎉' : 'Copy'}
28 </button>
29 )
30}

It takes a text prop and copies it to the clipboard when clicked. It uses the useState hook to manage a isCopied state, which toggles the button's text between 'Copy' and 'Copied! 🎉'. After copying, it resets the button text back to 'Copy' after 10 seconds.

It's using the the Web API navigator.clipboard.writeText to copy the text prop to the user's clipboard, which is supported in all modern browsers. I then pass the full code block as the text prop inside the HighlightedCode component.

bright-code-extensions.tsx
1<CopyButton text={code} />

Themes

My favorite part about Bright is that it supports VS Code's syntax highlighting. This is such a delight to use, because I can use any of the themes that I'm already familiar with.

You can make your own theme, or extend any VS Code theme. You can use another of Code Hike's tools, Bright Theme Generator, to create your own theme.

Conclusion

Bright is a versatile and powerful library for adding syntax highlighting to your React Server Components. Its ease of use, customization options, and server-side rendering support make it a great choice for any developer looking to enhance their application's code readability and overall user experience.

Lauro Silva
Written by Lauro Silva

Lauro is a software developer and educator who loves shipping great products and creating accessible educational content for developers. Currently, they are teaching React, TypeScript, and full-stack development with Next.js.