Zum Hauptinhalt springen
Echtzeit-Radar & Feeds
Alle RSS Feeds ➔
👥 Community & Social
YouTube Security VideosNutanix advances legacy and AI app management with AMD(01.10.2026 um 16:00 Uhr)
•
YouTube Security VideosPC-WELT: 32 TB SSD-Speicher in der HMX 6!(01.10.2026 um 16:15 Uhr)
••••
Videos & KonferenzenPC-WELT: 32 TB SSD-Speicher in der HMX 6!(01.10.2026 um 16:15 Uhr)
••
Sicherheitslücken (CVE)USN-8857-1: KCoreAddons vulnerability(01.10.2026 um 12:48 Uhr)
•••
YouTube Security VideosNutanix advances legacy and AI app management with AMD(01.10.2026 um 16:00 Uhr)
•
YouTube Security VideosPC-WELT: 32 TB SSD-Speicher in der HMX 6!(01.10.2026 um 16:15 Uhr)
••••
Videos & KonferenzenPC-WELT: 32 TB SSD-Speicher in der HMX 6!(01.10.2026 um 16:15 Uhr)
••
Sicherheitslücken (CVE)USN-8857-1: KCoreAddons vulnerability(01.10.2026 um 12:48 Uhr)
•••
Intelligence View
⚡ tsecurity.de Intelligence

How to Create a Next.js Blog - Part 2: Table of Contents, Search, and Categories

Adding Interactive Features to Your Next.js Blog In Part 1, we set up the foundation of your blog with a listing page. Now we'll add individual post pages, a…

Beitrag
0
Seite
0
↗ Quelle (dev.to)
Social ReaktionenReagiere als Erste:r — dein Feedback zählt!

Adding Interactive Features to Your Next.js Blog



In Part 1, we set up the foundation of your blog with a listing page. Now we'll add individual post pages, a table of contents for easy navigation, search functionality, and a category filtering system.



What You'll Build



By the end of Part 2, you'll have:




  • Individual blog post pages with MDX rendering

  • A table of contents component with active heading tracking

  • Client-side search functionality

  • Category filtering system with dedicated category pages

  • Enhanced blog listing with search and filters






Prerequisites



Before starting, make sure you've completed Part 1 and have:




  • A working Next.js blog with MDX support

  • shadcn/ui components installed

  • Sample blog posts in content/blog/

  • Basic understanding of React hooks (useState, useMemo)






Step 1: Creating Dynamic Post Pages



Let's create individual pages for each blog post using Next.js dynamic routes.






Updating posts.ts



Next, we need to add a function to get individual post data. Update lib/posts.ts:




export async function getPostData(slug: string): Promise<Post> {
const fullPath = path.join(postsDirectory, `${slug}.mdx`)
try {
await fs.stat(fullPath)
} catch (error) {
throw new Error('Post not found')
}
const fileContents = await fs.readFile(fullPath, 'utf8')

// Use gray-matter to parse the post metadata section
const matterResult = matter(fileContents)

// Combine the data with the id and content
return {
slug,
title: matterResult.data.title,
date: matterResult.data.date,
description: matterResult.data.description,
content: matterResult.content,
image: matterResult.data.image,
image_dark: matterResult.data.image_dark,
category: matterResult.data.category,
}
}






This function reads a specific MDX file and returns both the frontmatter and content.






Creating the Dynamic Route



Create app/blog/[slug]/page.tsx:




import { getPostData, getAllPostSlugs } from '@/lib/posts'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import rehypeSlug from 'rehype-slug'

type PageParams = Promise<{ slug: string }>

export async function generateMetadata({
params,
}: {
params: PageParams
}): Promise<Metadata> {
try {
const { slug } = await params
const post = await getPostData(slug)
return {
title: post.title,
description: post.description,
}
} catch (e) {
return {
title: 'Post not found',
description: 'The post you are looking for does not exist.',
}
}
}

export async function generateStaticParams() {
const posts = await getAllPostSlugs()
return posts.map((post) => ({
slug: post.slug,
}))
}

export default async function PostPage({ params }: { params: PageParams }) {
const { slug } = await params
try {
const post = await getPostData(slug)
const words = post.content.split(/\s+/g).filter(Boolean).length
const readingTime = Math.ceil(words / 200) // ~200 words per minute

return (
<div className="container mx-auto px-4 py-16 max-w-4xl">
<Link
href="/blog"
className="mb-4 flex items-center text-sm text-muted-foreground hover:underline"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to blog
</Link>

<h1 className="mb-2 text-3xl font-extrabold leading-tight tracking-tighter md:text-4xl">
{post.title}
</h1>
<p className="mb-6 text-sm text-muted-foreground">
{formatDate(post.date)} · {readingTime} min read
</p>

<article className="prose dark:prose-invert max-w-none">
<MDXRemote
source={post.content}
options={{
mdxOptions: {
rehypePlugins: [rehypeSlug],
},
}}
/>
</article>
</div>
)
} catch (e) {
notFound()
}
}






Key features:





  • generateStaticParams() - Pre-generates all post pages at build time for optimal performance


  • generateMetadata() - Creates SEO-friendly metadata for each post


  • MDXRemote - Renders MDX content with React Server Components


  • notFound() - Shows 404 page if post doesn't exist


  • Reading time - Calculates estimated reading time






Adding the formatDate Utility



Let's add a utility function to format dates. Add this to lib/utils.ts:




export function formatDate(date: string): string {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}






This function takes a date string (like '2026-01-10') and returns a nicely formatted date (like 'January 10, 2026').






Installing Required Packages



Make sure you have the typography plugin for prose styling:




npm install @tailwindcss/typography






Next.js 16 uses Tailwind CSS v4, which configures plugins in CSS rather than a config file. Add the typography plugin to app/globals.css:




@import "tailwindcss";
@plugin "@tailwindcss/typography";






Add the @plugin line after the @import "tailwindcss" line that's already in your globals.css.



The prose class provides beautiful typography for your MDX content.






Step 2: Implementing Table of Contents



A table of contents helps readers navigate long blog posts. We'll create a component that extracts headings and highlights the active section.






Installing rehype-slug



First, install the rehype-slug package to automatically generate IDs for headings:




npm install rehype-slug









Creating the TOC Utility



Create lib/toc.ts:




export interface TocItem {
id: string
text: string
level: number
}

function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.trim()
}

export function extractHeadings(content: string): TocItem[] {
// Match markdown headings: ## Heading Text or ### Heading Text
const headingRegex = /^(#{2,4})\s+(.+)$/gm
const headings: TocItem[] = []
let match

while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length // Number of # symbols
const text = match[2].trim()
const id = slugify(text)

if (id && text) {
headings.push({
id,
text,
level,
})
}
}

return headings
}






This utility:





  • slugify() - Converts heading text to URL-friendly IDs (matching what rehype-slug generates)


  • extractHeadings() - Parses markdown headings (##, ###, ####) from the raw MDX content






Creating the Table of Contents Component



Create components/table-of-contents.tsx:




'use client'

import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'

interface TocItem {
id: string
text: string
level: number
}

interface TableOfContentsProps {
items: TocItem[]
}

export function TableOfContents({ items }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>('')

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id)
}
})
},
{
rootMargin: '-128px 0px -66% 0px', // Account for fixed header
threshold: 0,
}
)

// Observe all heading elements
items.forEach((item) => {
const element = document.getElementById(item.id)
if (element) {
observer.observe(element)
}
})

// Check scroll position to highlight "Introduction" when at top
const handleScroll = () => {
if (window.scrollY < 200) {
setActiveId('')
}
}

window.addEventListener('scroll', handleScroll)
handleScroll() // Check initial position

return () => {
items.forEach((item) => {
const element = document.getElementById(item.id)
if (element) {
observer.unobserve(element)
}
})
window.removeEventListener('scroll', handleScroll)
}
}, [items])

const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id?: string) => {
e.preventDefault()
if (id) {
const element = document.getElementById(id)
if (element) {
const offset = 128 // Match scroll-margin-top
const elementPosition = element.getBoundingClientRect().top
const offsetPosition = elementPosition + window.pageYOffset - offset

window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
})
}
} else {
// Scroll to top
window.scrollTo({
top: 0,
behavior: 'smooth',
})
}
}

if (items.length === 0) {
return null
}

return (
<aside className="hidden xl:block w-64 flex-shrink-0">
<div className="sticky top-32">
<h2 className="text-sm font-semibold text-foreground mb-4">
Table of Contents
</h2>
<nav className="space-y-1.5 max-h-[calc(100vh-12rem)] overflow-y-auto">
<a
href="#"
onClick={(e) => handleClick(e)}
className={cn(
'block text-sm transition-colors py-1 rounded-md pl-2 font-medium',
activeId === '' || !activeId
? 'text-foreground font-medium bg-accent/50'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/30'
)}
>
Introduction
</a>
{items.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
onClick={(e) => handleClick(e, item.id)}
className={cn(
'block text-sm transition-colors py-1 rounded-md',
item.level === 2 && 'pl-2 font-medium',
item.level === 3 && 'pl-6 text-muted-foreground',
item.level === 4 && 'pl-10 text-muted-foreground',
activeId === item.id
? 'text-foreground font-medium bg-accent/50'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/30'
)}
>
{item.text}
</a>
))}
</nav>
</div>
</aside>
)
}






Key features:





  • IntersectionObserver - Tracks which heading is currently visible


  • Smooth scrolling - Smoothly scrolls to headings when clicked


  • Active state - Highlights the current section


  • Responsive - Hidden on smaller screens, visible on xl screens


  • Sticky positioning - Stays visible while scrolling






Updating the Post Page



Update app/blog/[slug]/page.tsx to include the TOC. Here's the complete updated file:




import { getPostData, getAllPostSlugs } from '@/lib/posts'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { extractHeadings } from '@/lib/toc'
import { TableOfContents } from '@/components/table-of-contents'
import rehypeSlug from 'rehype-slug'

type PageParams = Promise<{ slug: string }>

export async function generateMetadata({
params,
}: {
params: PageParams
}): Promise<Metadata> {
try {
const { slug } = await params
const post = await getPostData(slug)
return {
title: post.title,
description: post.description,
}
} catch (e) {
return {
title: 'Post not found',
description: 'The post you are looking for does not exist.',
}
}
}

export async function generateStaticParams() {
const posts = await getAllPostSlugs()
return posts.map((post) => ({
slug: post.slug,
}))
}

export default async function PostPage({ params }: { params: PageParams }) {
const { slug } = await params
try {
const post = await getPostData(slug)
const words = post.content.split(/\s+/g).filter(Boolean).length
const readingTime = Math.ceil(words / 200) // ~200 words per minute
const tocItems = extractHeadings(post.content)

return (
<div className="container mx-auto px-4 py-16">
<div className="flex gap-8">
<TableOfContents items={tocItems} />
<div className="flex-1 max-w-4xl">
<Link
href="/blog"
className="mb-4 flex items-center text-sm text-muted-foreground hover:underline"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to blog
</Link>

<h1 className="mb-2 text-3xl font-extrabold leading-tight tracking-tighter md:text-4xl">
{post.title}
</h1>
<p className="mb-6 text-sm text-muted-foreground">
{formatDate(post.date)} · {readingTime} min read
</p>

<article className="prose dark:prose-invert max-w-none">
<MDXRemote
source={post.content}
options={{
mdxOptions: {
rehypePlugins: [rehypeSlug],
},
}}
/>
</article>
</div>
</div>
</div>
)
} catch (e) {
notFound()
}
}






What changed:




  • Added imports for extractHeadings, TableOfContents, and rehypeSlug

  • Added tocItems extraction from post content

  • Wrapped the layout with a flex container for the TOC sidebar

  • Removed max-w-4xl from the container and moved it to the content div

  • Added rehypeSlug plugin to MDXRemote to auto-generate heading IDs






Step 3: Adding Search Functionality



Let's add client-side search to filter posts by title and description.






Creating the BlogList Component



Create components/blog-list.tsx:




"use client"

import { useState, useMemo } from 'react'
import Link from 'next/link'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Search } from 'lucide-react'
import { PostMatter } from '@/lib/posts'
import { formatDate } from '@/lib/utils'

interface BlogListProps {
posts: PostMatter[]
}

export function BlogList({ posts }: BlogListProps) {
const [searchQuery, setSearchQuery] = useState('')

const filteredPosts = useMemo(() => {
if (!searchQuery.trim()) {
return posts
}

const query = searchQuery.toLowerCase()
return posts.filter(
(post) =>
post.title.toLowerCase().includes(query) ||
post.description.toLowerCase().includes(query)
)
}, [posts, searchQuery])

return (
<div className="w-full">
{/* Search Input */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="Search posts..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>

{/* Results count */}
{searchQuery && (
<p className="mb-6 text-sm text-muted-foreground">
{filteredPosts.length} {filteredPosts.length === 1 ? 'post' : 'posts'} found
</p>
)}

{/* Posts List */}
{filteredPosts.length === 0 ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">No posts found matching your search.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{filteredPosts.map((post) => (
<Link
href={`/blog/${post.slug}`}
key={post.slug}
className="group block"
>
<article className="h-full rounded-lg border bg-card p-6 transition-colors hover:border-primary">
{post.category && (
<Badge variant="secondary" className="mb-2">
{post.category}
</Badge>
)}
<h2 className="mb-2 text-xl font-semibold group-hover:text-primary transition-colors">
{post.title}
</h2>
<p className="mb-4 text-sm text-muted-foreground">
{post.description}
</p>
<time className="text-xs text-muted-foreground">
{formatDate(post.date)}
</time>
</article>
</Link>
))}
</div>
)}
</div>
)
}






Key features:





  • useState - Manages search query state


  • useMemo - Memoizes filtered results for performance


  • Case-insensitive search - Searches both title and description


  • Real-time filtering - Updates as you type


  • Empty state - Shows message when no results found






Updating the Blog Page



Now let's update app/blog/page.tsx to use the BlogList component instead of the inline grid. Replace the entire file:




import { getSortedPostsData } from '@/lib/posts'
import { BlogList } from '@/components/blog-list'
import type { Metadata } from 'next'

export const metadata: Metadata = {
title: 'Blog',
description: 'Read our latest blog posts and tutorials.',
}

export default async function Blog() {
const posts = await getSortedPostsData()

return (
<div className="container mx-auto px-4 py-16">
<div className="mb-12 text-center">
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
Blog
</h1>
<p className="mt-6 text-lg text-muted-foreground">
Read our latest posts and tutorials
</p>
</div>
<BlogList posts={posts} />
</div>
)
}






What changed from Part 1:




  • Removed Link and Badge imports (now handled inside BlogList)

  • Added BlogList import

  • Replaced the inline <div className="grid ..."> with posts mapping with the <BlogList posts={posts} /> component



The BlogList component now handles rendering the posts grid, search functionality, and will later handle category filtering.






Step 4: Building the Category System



Now let's add category filtering with dedicated category pages.






Adding Category Functions



Update lib/posts.ts:




export async function getAllCategories(): Promise<string[]> {
const posts = await getSortedPostsData()
const categories = posts
.map((post) => post.category)
.filter((category): category is string => Boolean(category))
return Array.from(new Set(categories)).sort()
}

export async function getPostsByCategory(category: string): Promise<PostMatter[]> {
const posts = await getSortedPostsData()
return posts.filter((post) => post.category === category)
}

export async function getCategoryBySlug(slug: string): Promise<string | null> {
const categories = await getAllCategories()
const matchedCategory = categories.find(
(category) => slugify(category) === slug.toLowerCase()
)
return matchedCategory || null
}






Add the slugify utility to lib/utils.ts:




export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/[\s_-]+/g, '-') // Replace spaces, underscores, and multiple hyphens with single hyphen
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
}

export function getCategorySlug(category: string): string {
return slugify(category)
}






Now add the import at the top of lib/posts.ts:




import { slugify } from '@/lib/utils'









Creating Category Pages



Create app/blog/category/[category]/page.tsx:




import { getSortedPostsData, getAllCategories, getCategoryBySlug } from '@/lib/posts'
import { getCategorySlug } from '@/lib/utils'
import { BlogList } from '@/components/blog-list'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'

type PageParams = Promise<{ category: string }>

export async function generateMetadata({
params,
}: {
params: PageParams
}): Promise<Metadata> {
const { category } = await params
const matchedCategory = await getCategoryBySlug(category)

if (!matchedCategory) {
return {
title: 'Category not found',
description: 'The category you are looking for does not exist.',
}
}

return {
title: `${matchedCategory} | Blog`,
description: `Browse all ${matchedCategory.toLowerCase()} posts.`,
}
}

export async function generateStaticParams() {
const categories = await getAllCategories()
return categories.map((category) => ({
category: getCategorySlug(category),
}))
}

export default async function CategoryPage({ params }: { params: PageParams }) {
const { category } = await params
const matchedCategory = await getCategoryBySlug(category)

if (!matchedCategory) {
notFound()
}

// Get all posts - BlogList will filter by currentCategory
const posts = await getSortedPostsData()

return (
<div className="container mx-auto px-4 py-16">
<div className="mb-12 text-center">
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
{matchedCategory}
</h1>
<p className="mt-6 text-lg text-muted-foreground">
Browse all {matchedCategory.toLowerCase()} posts
</p>
</div>
<BlogList posts={posts} currentCategory={matchedCategory} />
</div>
)
}






Key point: We pass all posts to BlogList along with currentCategory. This way, BlogList can display all category filter buttons while showing only the posts from the selected category.






Adding Category Filters to BlogList



Update components/blog-list.tsx to include category filters. Here's the complete updated file:




"use client"

import { useState, useMemo } from 'react'
import Link from 'next/link'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Search } from 'lucide-react'
import { PostMatter } from '@/lib/posts'
import { formatDate, getCategorySlug } from '@/lib/utils'

interface BlogListProps {
posts: PostMatter[]
currentCategory?: string | null
}

export function BlogList({ posts, currentCategory = null }: BlogListProps) {
const [searchQuery, setSearchQuery] = useState('')

// Get unique categories from posts
const categories = useMemo(() => {
const cats = posts
.map((post) => post.category)
.filter((category): category is string => Boolean(category))
return Array.from(new Set(cats)).sort()
}, [posts])

const filteredPosts = useMemo(() => {
let filtered = posts

// Filter by category if currentCategory is set
if (currentCategory) {
filtered = filtered.filter((post) => post.category === currentCategory)
}

// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase()
filtered = filtered.filter(
(post) =>
post.title.toLowerCase().includes(query) ||
post.description.toLowerCase().includes(query)
)
}

return filtered
}, [posts, searchQuery, currentCategory])

return (
<div className="w-full">
{/* Search Input */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="Search posts..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>

{/* Category Filters */}
{categories.length > 0 && (
<div className="mb-8">
<div className="flex flex-wrap gap-2">
<Link href="/blog">
<Button
variant={currentCategory === null ? 'default' : 'outline'}
size="sm"
>
All
</Button>
</Link>
{categories.map((category) => {
const categoryUrl = `/blog/category/${getCategorySlug(category)}`
const isActive = currentCategory === category
return (
<Link key={category} href={categoryUrl}>
<Button
variant={isActive ? 'default' : 'outline'}
size="sm"
>
{category}
</Button>
</Link>
)
})}
</div>
</div>
)}

{/* Results count */}
{(searchQuery || currentCategory) && (
<p className="mb-6 text-sm text-muted-foreground">
{filteredPosts.length} {filteredPosts.length === 1 ? 'post' : 'posts'} found
{currentCategory && ` in "${currentCategory}"`}
</p>
)}

{/* Posts List */}
{filteredPosts.length === 0 ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">No posts found matching your search.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{filteredPosts.map((post) => (
<Link
href={`/blog/${post.slug}`}
key={post.slug}
className="group block"
>
<article className="h-full rounded-lg border bg-card p-6 transition-colors hover:border-primary">
{post.category && (
<Badge variant="secondary" className="mb-2">
{post.category}
</Badge>
)}
<h2 className="mb-2 text-xl font-semibold group-hover:text-primary transition-colors">
{post.title}
</h2>
<p className="mb-4 text-sm text-muted-foreground">
{post.description}
</p>
<time className="text-xs text-muted-foreground">
{formatDate(post.date)}
</time>
</article>
</Link>
))}
</div>
)}
</div>
)
}






What changed from the previous version:




  • Added Button import from shadcn/ui

  • Added getCategorySlug import from utils

  • Added currentCategory prop to the interface

  • Added categories extraction using useMemo

  • Updated filteredPosts to filter by category when set

  • Added category filter buttons section

  • Updated results count to show current category






What's Next



Congratulations! You've completed Part 2. You now have:




  • Individual post pages with MDX rendering

  • Table of contents with active heading tracking

  • Search functionality

  • Category filtering system



In Part 3, we'll add:




  • Related posts feature

  • Share buttons

  • Enhanced code blocks

  • SEO optimization

  • Image optimization

Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten How to Create a Next.js Blog - Part 2: Table of Contents, Search, and Categories

Thematisch verwandte Begriffe: Create, Nextjs, Blog, Part · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

💬 Kommentare werden geladen…
Zum Aktualisieren ziehen
tsecurity.de Icon
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag