2025 © Ty Qualters. Built with .
2025 © Ty Qualters. Built with .
Before starting this, I would highly suggest you learn the basics of HTML , CSS , and JavaScript . Really harp on JavaScript, but if you know absolutely nothing about web development, only spend a couple of weeks, and practice before coming back.
You will also need to learn React and Next.js . However, instead of going through a deep dive into them, just learn the basics (File Structure, JSX, and Components). Everything else can be picked up as needed.
The Fireship YouTube channel has “X in 100 seconds” videos for React, Next.js, and TailwindCSS.
This tutorial will take you through setting up a static site using Nextra . This static site will allow you to host your blog posts, write-ups, portfolio, and really whatever your heart desires — for FREE!
We will utilize the Blog theme, but we will ultimately be building a custom theme to use.
Please make sure you have Node.js and NPM installed on your system prior to continuing.
The first step is creating a new Next.js project. For this, you will want to go to a good directory you want your project to be in and run npx create-next-app@latest website-name-goes-here
.
When prompted, you will want to use:
Then cd
into your new project directory. You will then need to install some more packages and tools.
First, run npm i nextra nextra-theme-blog react-icons next-themes
.
This will install everything you need for Nextra, React-Icons , and Next-Themes (required for Light/Dark mode).
If you are going to run a backend service, I would also add server-only
to import into critical server components. This helps prevent backend code being accidentally leaked into client side code.
After installing those packages, you now need to run npm i -D pagefind
.
Lastly, you really should install shadcn . This will allow you to use pre-existing UI components instead of having to create your own.
Just run npx shadcn@latest init
and select your preferred theme (I chose Neutral). To use a component, you will need to run npx shadcn@latest add component-to-import
.
Ex. npx shadcn@latest add button
You can then import it into your code with import {Component} from '@/components/ui/component-to-import'
.
Ex. import {Button} from '@/components/ui/button
Time to set up.
/next.config.ts
import nextra from 'nextra'
const withNextra = nextra({})
export default withNextra({
turbopack: {
resolveAlias: {
'next-mdx-import-source-file': './src/mdx-components.jsx'
}
}
})
/package.json
Add "postbuild": "pagefind --site .next/server/app --output-path public/_pagefind"
under scripts.
/tailwind.config.cjs
module.exports = {
darkMode: 'class',
content: ['./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./theme.config.tsx',],
theme: {},
extend: {},
plugins: [],
};
/theme.config.tsx
export default {
nextThemes: {
attribute: 'class', // Or 'data-theme' depending on your Tailwind config
defaultTheme: 'system', // Set a default theme
disableTransitionOnChange: true, // Disable transitions during theme change
storageKey: 'nextra-theme', // Optional: Change the localStorage key
},
}
/src/mdx-components.jsx
import { useMDXComponents as getThemeComponents } from 'nextra-theme-blog' // nextra-theme-blog or your custom theme
const themeComponents = getThemeComponents()
export function useMDXComponents(components) {
return {
...themeComponents,
...components
}
}
/src/app/layout.tsx
import React from 'react'
import Link from 'next/link'
import Header from '@/components/header'
import { NextraTheme } from '@/components/theme'
import type { Metadata } from "next";
import { JetBrains_Mono } from "next/font/google";
import "./globals.css";
// Loading Screen
import { Suspense } from 'react';
import Loading from './loading';
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
weight: ['400', '500', '700'], // adjust as needed
display: 'swap',
})
export const metadata: Metadata = {
title: "Your title",
description: "Your description",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<Head />
<body className={`${jetbrainsMono.className} antialiased`}>
<NextraTheme>
<main className="p-5 pb-0.5">
{/* Your header */}
<Header />
{/* Content area */}
<div className="block max-w-5xl w-full mx-auto prose dark:prose-invert">
<Suspense fallback={<Loading />}>
{children}
</Suspense>
</div>
</main>
</NextraTheme>
</body>
</html>
);
}
/src/app/globals.css Customize as needed. Some of this might have been generated by shadcn too.
@import "tailwindcss";
/* Optional: import Nextra theme styles */
@import 'nextra-theme-blog/style.css';
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/* or nextra-theme-blog/style.css */
@variant dark (&:where(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
html.dark {
--background: #0a0a0a;
--foreground: #ededed;
}
h1 {
@apply mb-0.5;
}
article.container {
width: 100% !important;
margin-top: 0.125em !important;
margin-bottom: 0.125em !important;
}
h1 {
@apply text-3xl;
}
h2 {
@apply text-2xl;
}
h3 {
@apply text-xl;
}
ol {
@apply list-decimal ml-9;
}
ul {
@apply list-disc ml-9;
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/src/app/loading.tsx This is the loading screen.
export default function Loading() {
return (
<>
<div className="fixed inset-0 flex items-center justify-center bg-white z-50">
<div className="text-xl font-semibold text-black mr-4">Loading...</div>
<div className="w-12 h-12 border-4 border-gray-300 border-t-blue-600 rounded-full animate-spin"></div>
</div>
</>
);
}
/src/app/not-found.mdx This is the 404 page.
---
title: "404 - Page Not Found"
search: false
hidden: true
hideTimestamp: true
layout: raw
---
# 404 - Page Not Found
Sorry, the page you're looking for does not exist.
[Go back home](/)
/src/components/header.tsx
import Link from 'next/link'
import { ThemeSwitch } from 'nextra-theme-blog'
import { Search } from 'nextra/components'
export default async function Header() {
return (
<>
<nav className="flex flex-col gap-4 px-4 py-4 mb-8 items-center">
<h2 className='inline-block underline'>Your Header</h2>
{/* Top: nav links */}
<div className="flex flex-wrap gap-6">
<Link className="hover:text-blue-500 cursor-pointer" href="/">Home</Link>
<Link className="hover:text-blue-500 cursor-pointer" href="#">Link1</Link>
<Link className="hover:text-blue-500 cursor-pointer" href="#">Link2</Link>
<Link className="hover:text-blue-500 cursor-pointer" href="#">Link3</Link>
</div>
{/* Bottom: search and theme switch */}
<div className="flex gap-6">
<Search placeholder='Search site' />
<ThemeSwitch />
</div>
</nav>
</>
)
}
/src/components/theme.tsx
import { ThemeProvider } from 'next-themes'
import type { FC, ReactNode } from 'react'
export const NextraTheme: FC<{
children: ReactNode
}> = ({ children }) => {
return (
<>
<ThemeProvider attribute="class">
{children}
</ThemeProvider>
</>
)
}
/src/components/posts.jsx This is for inserting Latest Posts.
import { getLatestPosts } from '@/lib/get-latest-posts'
import { PostCard } from 'nextra-theme-blog'
export default async function Posts({ className, route }) {
const posts = await getLatestPosts(10, route)
return (
<section className={className}>
<h2 className="text-xl font-bold mb-6 underline">Latest Posts</h2>
<div className="space-y-6">
{!posts || posts.length === 0 ? <p>No posts found.</p> : posts
.filter(post => post.frontMatter !== undefined)
.map(post => (
<PostCard key={post.route} post={post} />
))}
</div>
</section>
)
}
/src/components/spacer.tsx This is for spacing in MDX files.
export default function Spacer({ size } : { size?: number }) {
return (<div className={`block`} style={{ marginBlock: `${size ?? 1}em` }} />)
}
/src/lib/get-latest-posts.js This is for getting the latest posts.
import { getPageMap } from 'nextra/page-map'
import { normalizePages } from 'nextra/normalize-pages'
export async function getLatestPosts(limit = 10, baseRoute = '/blog') {
const list = (await getPageMap(baseRoute)) ?? []
if (!Array.isArray(list) || list.length === 0) return []
let { directories } = normalizePages({ list, route: baseRoute })
directories = directories.filter(post => post.route != baseRoute)
const allPosts = flattenPages(directories)
const datedPosts = allPosts
.filter(post => post.frontMatter?.date)
.sort(
(a, b) =>
new Date(b.frontMatter.date).getTime() -
new Date(a.frontMatter.date).getTime()
)
// If we have enough dated posts, return only the top ones
if (datedPosts.length >= limit) {
return datedPosts.slice(0, limit)
}
// Otherwise, fill in with undated posts
const undatedPosts = allPosts.filter(post => !post.frontMatter?.date)
const remainingSlots = limit - datedPosts.length
const additionalPosts = undatedPosts.slice(0, remainingSlots)
return [...datedPosts, ...additionalPosts]
}
function flattenPages(nodes) {
const flat = []
for (const node of nodes) {
if (node.children) {
flat.push(...flattenPages(node.children))
} else {
flat.push(node)
}
}
return flat
}
That was a lot. However, now we have the foundation laid out. It is important to understand how Nextra will work now.
Your /src/app/layout.tsx file will apply to every file in your website. So it needs to be very general and only include what is required.
Each subfolder in /src/app will act as another directory. For indexing purposes (i.e., latest posts), it is recommended that you use page.mdx instead of page.tsx.
The difference between MDX and TSX is that MDX is Markdown with the ability to use components (JSX), whereas TSX is TypeScript with the ability to use components (JSX).
Example MDX file
---
page: "Title"
description: "Description"
---
import Spacer from '@/components/spacer'
import {Button} from '@/components/ui/button'
## This is a Level 2 Heading
<Spacer size={2} />
This is my next text.
<Spacer />
This is a shadcn button: <Button>My button</Button>
Great, now let’s create your blog page. You can duplicate and adjust this procedure if you would like to create a separate section, like for write-ups and guides.
/src/app/blog/page.mdx
---
title: "Blog"
description: "Personal blog"
search: false
hidden: false
hideTimestamp: true
---
import Posts from '@/components/posts'
View the newest posts.
<Posts route='/blog' className="mt-8" />
/src/app/blog/get-posts.js
import { normalizePages } from 'nextra/normalize-pages'
import { getPageMap } from 'nextra/page-map'
export async function getPosts() {
const page = '/blog'; // your route goes here
const list = await getPageMap(page) ?? [];
if (!Array.isArray(list) || list.length === 0) return []
const { directories } = normalizePages({
list: list,
route: page
})
return directories
.filter(post => post.name !== 'index')
.sort((a, b) => new Date(b.frontMatter.date) - new Date(a.frontMatter.date))
}
export async function getTags() {
const posts = await getPosts()
const tags = posts.flatMap(post => post.frontMatter.tags)
return tags
}
/src/app/blog/rss.xml/route.jsx This is your RSS feed.
import { getPosts } from '../get-posts'
const CONFIG = {
title: 'Your Blog',
siteUrl: 'https://your-site.com',
description: 'Latest blog posts',
lang: 'en-us'
}
export async function GET() {
const allPosts = await getPosts()
const posts = allPosts
.map(
post => ` <item>
<title>${post.title}</title>
<description>${post.frontMatter.description}</description>
<link>${CONFIG.siteUrl}${post.route}</link>
<pubDate>${new Date(post.frontMatter.date).toUTCString()}</pubDate>
</item>`
)
.join('\n')
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>${CONFIG.title}</title>
<link>${CONFIG.siteUrl}</link>
<description>${CONFIG.description}</description>
<language>${CONFIG.lang}</language>
${posts}
</channel>
</rss>`
return new Response(xml, {
headers: {
'Content-Type': 'application/rss+xml'
}
})
}
Now for new posts, you would make a new directory, create a page.mdx file inside of it, and do whatever.
Example.
/src/app/blog/first-post/page.mdx
---
title: First Post
description: This is my first post on here!
---
This is my first post!
If you would like to import MDX into a TSX file.
Example.
/src/app/page.tsx
import MDXContent from './mycontent.mdx'
export default function MyPage() {
return (
<>
<p>Here is my markdown file</p>
<MDXContent />
</>
)
}
You may have noticed the weird intermix of JavaScript/JSX and TypeScript/TSX, which generally is not advisable in projects. However, we do this because of Type Validation in ESLint. Types are rather complex, and by avoid TypeScript in these files, we avoid any unneeded complexity in the production building step.
To build your project, simply run npm run build
. To run it for development, use npm run dev
.
Additionally, search indexing only works for production-built pages. That does not mean that you cannot search in development. It just means that you might have to run npm run build
before running npm run dev
to find a new page in the search bar.
Timestamps for features like getting the latest posts will prioritize manually set timestamps in the MDX file headings, but if those are not available, they will grab the timestamps from Git (from commits).
I would recommend you publish your Git repository to GitHub. GitHub now offers private repositories for free, so take full advantage of that.
Publishing your website is pretty simple. You can either use GitHub actions and GitHub pages, or preferably creating a Vercel account and connecting it to your GitHub repository.
If you use Vercel, you can either purchase a domain there, or buy one from another vendor and set the root and WWW records as a CNAME record pointing to the Vercel subdomain.
This may look like the following:
TYPE | NAME | CONTENT |
---|---|---|
CNAME | yourdomain.com | your-website.vercel.app |
CNAME | www | yourdomain.com |
If you are going to use any sort of API keys or backend logic in your website, I would highly recommend adding dotenv and adding .env to your .gitignore file. Keep it out of your GitHub repository (public or private). Add the environment variables directly into GitHub Actions secrets and variables or Vercel.
It is now up to you from here. Good luck in your endeavors!
— Ty