Minimalistic Blog Template

This documentation is made for Debian based systems, also it looks best on a wide screen


repo: https://github.com/CacheMeIfYouCan1/minimalBlogTemplate
demo: https://minimal-blog-template-five.vercel.app/

Table of Contents

  1. Introduction
  2. Setup
  3. Documentation
    1. Structure
    2. Base Components
    3. UI
    4. Logic
    5. Posts
  4. Customization
  5. License

Introduction:


This Blog Template was developed based on my personal portfolio, with an emphasis on keeping it lightweight. Many existing solutions are unnecessarily complex, offering more features than required for a simple blog setup. This project focuses on the essentials. Additionally, a content management system is planned to be implemented in the future.




Setup:

if nodejs, npx and create-next-app is already installed, you can skip the next steps, if not please install them:



$ sudo apt install nodejs npm
$ sudo npm install -g npm
$ sudo npm install -g create-next-app


download and build the template with 'npx create-next-app', then start it using 'npm run dev' or 'npm start'. Please note that with 'npm run dev' you can use turbopack for smoother development



$ npx create-next-app@latest blog-template --example https://github.com/CacheMeIfYouCan1/minimalBlogTemplate

$ npm run build

$ npm run dev




Documentation:

Structure


blog/
|- README.md
|- next.config.ts
|- postcss.config.mjs
|- .env.local
|- next-env.d.ts
|- package.json
|- tsconfig.json
|- public/
| |- posts/
| | |<id>
| | |- card/ 
| | | |- card.md
| | | |- pic.jpeg
| | |- postPage
| | | |- postPage.md
| |- icon.png
| |- (...)
|- src/
| |- lib/ 
| | |- encrypt 
| | | |- encrypt.ts 
| | |- sendmail 
| | | |- sendmail.ts
| |- theme/
| | |- theme.ts
| |- components/
| | |- ui/
| | | |- logo.tsx
| | | |- title.tsx
| | | |- navigation.tsx
| | | |- button.tsx
| | |- banner.tsx
| | |- cards.tsx
| | |- contactform.tsx
| | |- pgpkey.tsx
| | |- homepage.tsx
| | |- mailsent.tsx
| | |- footer.tsx
| | |- navbar.tsx
| | |- logo.tsx
| | |- title.tsx|
| |- app/
| | |- api/
| | | |- contact/
| | | | |- route.ts
| | |- blog/
| | | |- page.tsx
| | |- post/ 
| | | |- page.tsx
| | |- contact/ 
| | | | |- page.tsx
| | |- homepage/
| | | |- page.tsx | | |- mailsent/
| | | |- page.tsx | | |- favicon.ico | | |- globals.css | | |- layout.tsx | | |- page.tsx

Base Components

/next.config.ts:

Next.js configuration file. Contains configurations for the Next.js application

/postcss.config.mjs:

PostCSS configuration file. Contains configurations of CSS processing

/.env.local:

envoronment configuration file. Contains SMTP setup and PGP-Key.

important note: PGP-Key must be stored in base64 encoded form!

/package.json:

Package descriptor file. Contains metadata, scripts and dependencies

/next-env.d.ts:

Contains configuration for TypeScript compiler


UI

The User Interface is fully contained in app/src/content/. Each page as well as the footer and the header have their own directory, All pages, except the footer and the navbar use a page.tsx to display the components of the page as well provide any functionality which cannot be implemented in client components.

The footer and the navbar use footer.tsx and navbar.tsx to display the components. This is because they are not pages, but are imported as elements, which are included in all pages in the header and footer area.

the client components of the actual pages are stored in /<page>/components:

src/components/cards.tsx:


export default function Cards({
                  posts,
                }: {
                  posts: Array<{ name: string; content: string }>;
                }) {

const [search, setSearch] = useState(''); return (
/ TSX elements / ); }


This builds the list, containing all cards of the blogposts. This component takes the list of cards which are to displayed as an array and iterates through them, loading the content from /public/posts/<id>/card/.

There are two versions, one is displayed on smaller and the other is displayed on bigger screens to ensure mobile friendlyness. Tailwinds utility classes are used for this.

Each card is a link element which builds an URL with the post id and passes this as a parameter when navigating to src/app/content/blog/posts/page.tsx, where the content of the post with the corresponding id is loaded and presented.

src/components/banner.tsx:


export default function banner({
                  posts,
                  sortedBy,
                }: {
                  posts: Array<{ name: string; content: string }>;
                  sortedBy: string;
                }) {
    const [sort, setSort] = useState(sortedBy);
    const [search, setSearch] = useState('');

    const sortBy = () => {
        const newSort = sortedBy === 'ASCENDING' ? 'DESCENDING' : 'ASCENDING';
        setSort(newSort); 

        window.location.href = /content/blog?search=${search}&sort=${newSort};
    };
    const handleSearch = async (event: any) => {
        event.preventDefault();
        window.location.href = /content/blog?search=${search}&sort=${sort};
    };

    return (

        / TSX elements /

  );
}



this is the banner displayd on the blog page. It contains the Title, a subtitle, the search field and a sorting button. We also have two states which we use in this component:

const [sort, setSort] = useState(sortedBy);
const [search, setSearch] = useState('');


The sortBy function is triggered on click of the sorting button. The parameter is set to ASCENDING if any other parameter is passed than DESCENDING. Then a URL with the previous search and the current sort is created.

Please note that a ternary operator is used instead of an if/else. This is done because it provides a convenient way to toggle a value, which can still be declared as a const.

const sortBy = () => {
    const newSort = sortedBy === 'ASCENDING' ? 'DESCENDING' : 'ASCENDING';
    setSort(newSort); 

    window.location.href = /content/blog?search=${search}&sort=${newSort};
};


The handleSearch function works by routing to the blog page and transferring parameters through the URL, which are used to filter the post-card-list on reload.The search parameter is set on change of the input value. The submission of this form triggers handleSearch(), which uses the value to construct a new URL.

const handleSearch = async (event: any) => {
    event.preventDefault();
    window.location.href = /content/blog?search=${search}&sort=${sort};
};


Important note: if the clients want to sort and search, they need to sort first, then search the sorted results, as the sorting resets the search.

src/app/post/page.tsx:



export default async function Post(props: { searchParams: Promise<{ id?: string }> }) {


    const searchParams = await props.searchParams;
    const idQuery: string = await searchParams?.id || "";

    const postsDirectory = path.join(process.cwd(), 'public', 'posts');
    const postDirs = fs.readdirSync(postsDirectory);

    const pageFilePath = path.join(postsDirectory, idQuery, 'postPage', 'postPage.md');

    let fileContent: string;

    try{

    fileContent = fs.readFileSync(pageFilePath, 'utf-8');

    } catch {
     notFound();
    }
      return (
        / TSX elements /
  );
}




This contains the rendered markdown blogposts. To avoid an error when trying to render a nonexistend post ID, the fileContent is set in a try/catch block. If an error occurs, the NextJS standard 404 page is displayed.

src/components/contactform.tsx:


export default function ContactForm() {

    const router = useRouter();

    const [name, setName] = useState('');
    const [email, setEmail] = useState('');
    const [message, setMessage] = useState('');


const handleSubmit = async (event: React.SyntheticEvent) => { event.preventDefault(); const response = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name, email, message }), }); if (response.ok) { router.push('/content/mailsent'); } else { alert(response.status); } }; return ( / TSX elements / ); }

contains the contactform.

we use three states here, which are all set on change of the corresponding form elements.

const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');


The input form, as well as the text above it, are presented in two different versions. one version is displayed on bigger, the other version on smaller screens This is achieved using tailwinds utility classes.



on submission of the form, handleSubmit gets executed, which makes a POST request, sending the name, email and the message of the user to the backend in json format, where it gets encrypted and the mail gets send. If the response is positive, the user is redirected to /mailsent.

const handleSubmit = async (event: React.SyntheticEvent) => {
    event.preventDefault();

    const response = await fetch('/api/contact', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ name, email, message }),
    });

    if (response.ok) {
      router.push('/content/mailsent');
    } else {
      alert(response.status);
    }
};


important note: if the response is not 200, the status of the response gets alerted. This is practical for troubleshooting the SMTP setup, but a proper error message would look better for the client, in case of issues with sending the contact mail.



src/components/pgpkey.tsx:

export default function PgpKey() {

const router = useRouter();

const base64PublicKey = process.env.NEXT_PUBLIC_PGP_KEY!;
const cleanBase64PublicKey = base64PublicKey.trim();
const publicKeyArmored = atob(String(cleanBase64PublicKey));

const beginPgp = "-----BEGIN PGP PUBLIC KEY BLOCK-----";
const endPgp = "-----END PGP PUBLIC KEY BLOCK-----";
const finalPublicKeyArmored = publicKeyArmored.replace("-----BEGIN PGP PUBLIC KEY BLOCK-----", "").replace("-----END PGP PUBLIC KEY BLOCK-----", "").trim();

const toggle_pgp_mobile = () => {
    const pgp_key = document.getElementById('pgp_key_mobile');
    if(pgp_key){
        pgp_key.classList.toggle('hidden');
    }else {
        console.error('Element not found.');
    }
};

const toggle_pgp_desktop = () => {
    const pgp_key = document.getElementById('pgp_key_desktop');
    if(pgp_key){
        pgp_key.classList.toggle('hidden');
    }else {
        console.error('Element not found.');
    }
};


return (

    / TSX elements /

); }

component that contains the button, as well as the blog with the pgp key. The pgp key is stored in .env.local and should only be set there. Again we have a mobile and a desktop version.



The PGP-Key needs to get trimmed, decoded, and is stripped of the begin and end block, this is for easier styling.

const base64PublicKey = process.env.NEXT_PUBLIC_PGP_KEY!;
const cleanBase64PublicKey = base64PublicKey.trim();
const publicKeyArmored = atob(String(cleanBase64PublicKey));

const beginPgp = "-----BEGIN PGP PUBLIC KEY BLOCK-----";
const endPgp = "-----END PGP PUBLIC KEY BLOCK-----";
const finalPublicKeyArmored = publicKeyArmored.replace("-----BEGIN PGP PUBLIC KEY BLOCK-----", "").replace("-----END PGP PUBLIC KEY BLOCK-----", "").trim();


The button triggers the respective function, either toggle_pgp_desktop() or toggle_pgp_mobileThe functions toggle the hidden attribute of the corresponding element. Which reveals a block with a public pgp Key.

const toggle_pgp_mobile = () => {
    const pgp_key = document.getElementById('pgp_key_mobile');
    if(pgp_key){
        pgp_key.classList.toggle('hidden');
    }else {
        console.error('Element not found.');
    }
};

const toggle_pgp_desktop = () => {
    const pgp_key = document.getElementById('pgp_key_desktop');
    if(pgp_key){
        pgp_key.classList.toggle('hidden');
    }else {
        console.error('Element not found.');
    }
};



important note: the PGP Key is stripped off the BEGIN and END block. Instead these blocks are added manually, for more flexible styling

src/components/homepage.tsx:




export default function HomePage() {


    return (

        / TSX elements /
  );
}


this is the home page of the blog. Currently a button is displayed which links to the contact form and a little introduction.

src/components/mailsent.tsx:


export default function MailSent() {

return (

    / TSX elements /

);

}



redirect page for successfully sent mails. The user is presented with a button which links to the home page.

src/components/logo.tsx:


const Logo: React.FC = () => {


return (

    / TSX elements /

); };

export default Logo;



This is the logo in the top left. The logo is sourced from /public/icon.png. This script should be used to style the logo.

src/components/title.tsx:


const Title: React.FC = () => {


return (

    / TSX elements /

); };

export default Title;



Contains the title.

src/components/navigation.tsx:



const Navigation: React.FC = () => {

    const toggle_dropdown = () => {

const list = document.getElementById('dropdown_list'); if(list){ list.classList.toggle('hidden'); }else { console.error('Element with id "dropdown_list" not found.'); } }; return ( / TSX elements /
); }; export default Navigation;


The navigation is managed here. There are two different versions contained, one is used for smaller displays and the other is used for larger displays. This is done through tailwinds utility class 'md:'. The version which is displayed on the bigger screen places the navigation links in a row, while the version which is displayed on smaller screens provides a simple button, which on click expands the navigation table containing the links.



Toggling the navigation list works as following:



const toggle_dropdown = () => {

const list = document.getElementById('dropdown_list'); if(list){ list.classList.toggle('hidden'); }else { console.error('Element with id "dropdown_list" not found.'); } };


important note: the list of links is not dynamically generated. If another component is added, another element must be added, too.


Logic

src/app/api:

This directory is used for all API routes. API routes should only be implemented within this directory.

src/app/api/contact/route.ts:


export async function POST(request: NextRequest) {
    const { name, email, message } = await request.json();

    try{
        //check if the request is complete
        if (!name || !email || !message) {
            return new Response("Missing fields", { status: 400 });
        }  else {

        // merge, encrypt, send
        const responseData = { name, email, message };
        const encryptedData = await Encrypt(JSON.stringify(responseData));
        await SendMail(encryptedData);

        return new Response(encryptedData, { 
            status: 200, 
            headers: { 'Content-Type': 'application/json' }
            });
        }
    } catch (error) {
        return new Response('something went wrong!', { 
            status: 500, 
            headers: { 'Content-Type': 'application/json' }
            });
    }

}


Contains the route used by the contact form


takes a POST request which needs to contain following strings in json format:

  1. name
  2. email
  3. message

If any of these is missing, status 400 is returned. If all are present the strings get merged in json format, encrypted and send per mail. If an error occurs, returns status 500. This is ensured through a try/catch block in combination with an if/else statement:

try{
    if (!name || !email || !message) {
        return new Response("Missing fields", { status: 400 });
    }  else {

    /.../

    return new Response(encryptedData, { 
        status: 200, 
        headers: { 'Content-Type': 'application/json' }
        });
    }
} catch (error) {
    return new Response('something went wrong!', { 
        status: 500, 
        headers: { 'Content-Type': 'application/json' }
        });
}


if the name, email and message are properly passed and formated, the Logic makes sure its a JSON string, encrypts and sends the encrypted blob text through mail:



const responseData = { name, email, message };
const encryptedData = await Encrypt(JSON.stringify(responseData));
await SendMail(encryptedData);


Important note: its necessary to perform the encryption as well as the email transfer asynchronous

src/lib/:

Server side logic, which is not accessed directly by the UI, but rather is accessed through The API is stored here. Any additional functionalities which fall in this category should also be stored here.

Then name convention is /lib/componentDir/componentScript.ts

src/lib/encrypt/encrypt.ts:



export async function Encrypt(jsonData: string) {
    var encryptedString = ""
    try{
        //load Key
        const base64PublicKey = process.env.NEXT_PUBLIC_PGP_KEY;
        const publicKeyArmored = atob(String(base64PublicKey));
        const publicKey = await openpgp.readKey({ armoredKey: publicKeyArmored });
        const jsonString = JSON.stringify(jsonData);

//encrypt data encryptedString = await openpgp.encrypt({ message: await openpgp.createMessage({ text: jsonString }), encryptionKeys: publicKey });


}catch (err) { console.log(err); }
return encryptedString; }


This component takes a string as an argument, loads the pgp key, encrypts it using PGP and return the encrypted string. The public PGP-KEy is read from .env.local file, decoded and read as a public pgp key. The jsonData is casted into a json String and encrypted using the previously read PGP-Key. encryptedString is declared as an empty string, to ensure the function has a proper return, even if ehere is an error during encryption.

The key is loaded as a base64 string, which needs to get decoded, and read through openpgpjs as an armored key. Afterwards the passed jsonData is casted into a jsonString. This is a failsafe, if a wrong datatype is passed.

const base64PublicKey = process.env.NEXTPUBLICPGP_KEY;
const publicKeyArmored = atob(String(base64PublicKey));
const publicKey = await openpgp.readKey({ armoredKey: publicKeyArmored });
const jsonString = JSON.stringify(jsonData);




The encryption is done using openpgp.js, please note that this must happen asynchronous.

//encrypt data
encryptedString = await openpgp.encrypt({
    message: await openpgp.createMessage({ text: jsonString }), 
    encryptionKeys: publicKey
});


src/lib/sendmail/sendmail.ts:



export async function SendMail(jsonData: string) {

try {
    //create transporter
    const transporter = nodemailer.createTransport({
        host: process.env.SMTP_HOST!,
        port: Number(process.env.SMTP_PORT),
        auth: {
            user: process.env.SMTP_USER!,
            pass: process.env.SMTP_PASS!,
        }
    });
    //set content
    const content = {
        from: theme.mail.from!,
        to: theme.mail.to!,
        subject: theme.mail.subject!,
        text: jsonData,
        headers: {
            'Content-Type': 'text/plain; charset=UTF-8',
        },
    };
    //send mail
    transporter.sendMail(content, (error, info) => {
        if (error) {
            console.log('Error:', error);
        } else {
            console.log('Email sent:', info.response);
        }
    });

  
}catch (err) {
    console.log(err);
}

      
return jsonData;

}



Exports SendMail(), returns jsonData and takes one argument: jsonData which needs to be a string. Nodemailer is used for using SMTP. The SMTP configuration is read from .env.local.

The process of sending the mail contains three steps:

1. creating a transporter:

The transporter is created using the SMTP host, port and the auth data. All of these should be stored in .env.local and are accessed through. All values, except the port, need to be assigned using a non-null assertion operator. The port value should be casted into a Number.

If other values are needed, view the nodemailer documentation.



const transporter = nodemailer.createTransport({
        host: process.env.SMTP_HOST!,
        port: Number(process.env.SMTP_PORT),
        auth: {
            user: process.env.SMTP_USER!,
            pass: process.env.SMTP_PASS!,
        }
    });


2. setting the content:

All variables, except the text, are taken from the environmental variables in .env.local. They should be assigned using a non-null assertion operator. The text value is the jsonData variable passed.



const content = {
        from: process.env.MAIL_FROM!,
        to: process.env.MAIL_TO!,
        subject: process.env.MAIL_SUBJECT!,
        text: jsonData,
        headers: {
            'Content-Type': 'text/plain; charset=UTF-8',
        },
    };


Please not that following headers should be set, to avoid errors when displaying the encrypted PGP string:

headers: {
    'Content-Type': 'text/plain; charset=UTF-8',
},


3. Sending the Mail


transporter.sendMail(content, (error, info) => {
        if (error) {
            console.log('Error:', error);
        } else {
            console.log('Email sent:', info.response);
        }
    });


The mail gets send, using the previously created transporter with the previously set content and the response gets logged to the console. Please keep in mind that logging the response may be a security concern, since the information of the SMTP provider could be gathered.

It is very helpful to log the response during the development and configuration of the application, but it should be considered to remove this function before deploying the application.


Posts

public/:

Contains all publicly available resources like pictures and posts. No sensitive data should be stored here.

public/posts:

The posts are stored here as directories and must be named numerical. The name of the directoy serves as the ID of the post. The posts contain:

public/posts/card:

Here are the cards stored, which are displayed on the Blogpage. The content of the card must be saved as card.md. When sorting, they are sorted by their ID. When searching they are searched by the content of card.md. Markdown is rendered, for further explanation of Markdown usage, please view the first blogpost within the template.

public/posts/postPage:

Here is the actual Post stored. The post content needs to be stored as postPage.md. The content of the page is not searched by the search function. If pictures are added in the post, they can be also stored in /postPage. Markdown is rendered, for further explanation of Markdown usage, please view the first blogpost within the template.


Environment:


The environment carries the SMTP setup, as well as the public PGP key used for mail encryption. Below is an example setup. It is important to know that the .env should never be made public, if used with real data.


.env.local:


NEXT_PUBLIC_PGP_KEY -> public pgp key, which is used in multiple components. This needs to be encoded in base64


SMTP_HOST -> this is your SMTP server
SMTP_PORT -> This is your SMTP port
SMTP_USER -> SMTP username
SMTP_PASS -> SMTP password


MAIL_FROM -> the sender of the email from the contact form
MAIL_TO -> the receiver of the contact mail
MAIL_SUBJECT -> subject of the generated email

Customization:


A lot of the colors as well as the texts used can be customized without changing the source code of the Blog. The texts are set in from theme/theme.ts, while the colors and the markdown rendering is configured in /globals.css. The content of the blog posts can also be altered without changing the source code, since it is dynamically loaded from /public/posts/.


src/theme/theme.ts:


export const theme = {
  navbar: {
    title: 'Blog Template', // Title which is used in /content/navbar/title.tsx, displayed in the center of the topbar
  },
  homepage: {
    title: 'This is the main page of the blog',               // Title, which is used in /content/homepage/components/homepage.tsx, first title on the main page
    subtitle: 'the button below links to the contact page',    // Subtitle, which is used in /content/homepage/components/homepage.tsx, second title on the main page
    buttonText: 'GET IN TOUCH',                                // Button text which is used in /content/homepage/components/homepage.tsx, contact button
  },
  blog: {
    title: '"This is some quote related to the blog"',        // Title used in /content/blog/components/banner.tsx, main title of the blog page
    subtitle: 'said by someone, someday',                      // Subtitle used in /content/blog/components/banner.tsx, subtitle of the blog page
  },
  contact: {
    title: 'Contact',                                          // Title used in /content/contact/components/contactform.tsx main title of the contact page
    text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', // Text used in /content/contact/components/contactform.tsx main title of the contact page

  },
  email: {

    from: 'MAILSENDER',                                       // the sender of the email from the contact form
    to: 'MAILRECEIVER',                                       // the receiver of the contact mail
    subject: 'Contact Request',                               // subject of the generated email
  },
};



src/app/globals.css:

colors:

The general colors are set here. The top as well as the bottom bar are using the primary color, while the banners and the pgp block use the secondary color. The linear gradient is displayed whenever no color is set. The foreground is ised as a main text color.

:root {
  --background: linear-gradient( #bbbbbb, #808b96);

}


body {
 
  background: var(--background);
  font-family: Arial, Helvetica, sans-serif;
 
}

.primary-color {
    background-color: #78866B;
}

.secondary-color {
    background-color: #9C9A6D;
}


Markdown:

The markdown rendering is generally done using markdown-to-jsx, but some elements are customized.

One important customization involves the tables. They are rendered differently on small screens using a media query. The decision on how to render tables on smaller devices has changed several times. Some other Markdown-based blogs use scrollable elements, but during the creation of this blog, a deliberate choice was made to break with that pattern. Introducing a scrollable element on small screens was found to negatively impact usability.
Currently the table elements are stacked on smaller devices, so
1 2 3
becomes
1

2

3
this is still not optimal, but at least ensures a smooth scrolling experience when moving across the blogpost.

/*Markdown rendering*/

@media (max-width: 768px) { .markdown-content table, .markdown-content table th, .markdown-content table td {

display: block;
width: 100%;
border: none;

font-size: 12px;

} }

.markdown-content table { padding-top: 25px; padding-bottom: 25px; width: 100%; border-collapse: collapse; font-size:12px; margin-bottom: 20;

}

.markdown-content table th, .markdown-content table td { padding: 10px; border: 1px solid #000000;

}



Similar customization is applied to the codeblocks, the following styling ensures line breaks instead of overlow. This was also done to create a flawless scrolling experience. While this is not optimal, it provides a better feeling than codeblocks which overflow and can be scrolled to the right.



.markdown-content {
  word-wrap: break-word;
}

.markdown-content pre {
  background-color: #f4f4f4;
  padding: 15px;
  border-radius: 5px;
  white-space: pre-wrap;
word-wrap: break-word;
} .markdown-content code{ background-color: #666666; padding-top: 10px; padding-bottom: 10px; margin: 30px; display:block;
border-radius: 4px; font-family: 'Courier New', Courier, monospace; font-size: 0.9rem; }


The rest of the markdown rendering is styled as follwoing:



.markdown-content h1 {
  font-size: 50px;
  font-style: bold;
  text-align: center;
  padding-bottom: 50px;
  padding-top: 50px;
}

.markdown-content h2 {
  font-size: 35px;
font-style: bold; padding-bottom: 10px; padding-top: 35px; text-align:center; text-decoration: underline; } .markdown-content h3 { font-size: 30px; font-style: bold; padding-bottom: 10px; padding-top: 25px; text-align: center; } .markdown-content h4 { font-size: 25px; font-style: bold; padding-bottom: 10px; padding-top: 20px; } .markdown-content h5 { font-size: 20px; font-style: bold; padding-bottom: 10px; padding-top: 25px; } .markdown-content ol { list-style-type: decimal; padding-left: 50px; padding-top: 5px; padding-bottom: 5px; display: block } .markdown-content li { margin-bottom: 8px; } .markdown-content hr {
margin-bottom: 50px; margin-top: 50px; }


Further customization:

All further customization needs to be individually developed. Since it is kept fairly minimalistic, the codebase is rather small and can be overlooked very well. There are still a few considerations to keep in mind when implementing new features.

UI/UX:

Since each page is unique, it has its own directory containing a components/ folder, where components are organized based on their specific usage. If new features are introduced that share functionality with existing ones, it is recommended to create a centralized directory for components used across multiple pages—such as those related to searching or sorting.

Another important consideration is the blogs responsive design. To ensure that all pages display correctly across different devices, the layout adjusts based on screen size. Currently, both desktop and mobile versions are handled within the same files. This approach works well due to the blog’s minimal complexity. But as components become more complex, it is advisable to separate mobile and desktop versions to maintain clarity.

Following these practices will help ensure the codebase remains clean, scalable, and maintainable.

Server functionality:

api routes and the /lib/ directory are used to contain server side logic. It is advised to not mix them with the UI elements, to preserve readability of the blog. Also api routes should be seperated from the files in /lib, since the api routes are primarily accessed through their respected directory by NexJS per default.

Feel free to contact me if you need assistance, support or need custom features developed

License:

Creative Commons Legal Code

CC0 1.0 Universal

CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.

Statement of Purpose

The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work").

Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.

For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.

  1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following:

    i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; ii. moral rights retained by the original author(s) and/or performer(s); iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; v. rights protecting the extraction, dissemination, use and reuse of data in a Work; vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.

  2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose.

  3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose.

  4. Limitations and Disclaimers.

    a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.