DEV Community

Muhammad Usman
Muhammad Usman

Posted on

Next.js 14 App Router: Building Modern Full-Stack Applications

Next.js has revolutionized React development by providing a comprehensive framework that handles everything from routing to server-side rendering. With the introduction of the App Router in Next.js 13 and its maturation in Next.js 14, developers now have access to powerful features like Server Components, Server Actions, and improved data fetching patterns. This guide will take you through building a complete full-stack application using the latest Next.js features.

Understanding the App Router Architecture

The App Router represents a paradigm shift from the traditional Pages Router. Built on React's latest features, it introduces concepts like Server Components, which run on the server and can directly access databases and APIs without exposing sensitive data to the client.

Key Concepts:

  • Server Components: React components that render on the server
  • Client Components: Traditional React components that run in the browser
  • Server Actions: Functions that run on the server and can be called from client components
  • Streaming: Progressive rendering of UI components
  • Nested Layouts: Reusable UI that persists across routes

Project Structure and Setup

Let's start by creating a modern blog application with authentication, database integration, and real-time features.

# Create a new Next.js application
npx create-next-app@latest my-blog-app --typescript --tailwind --eslint --app
cd my-blog-app

# Install additional dependencies
npm install @prisma/client prisma @auth/prisma-adapter next-auth
npm install @types/bcryptjs bcryptjs
npm install zod react-hook-form @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode

Database Schema with Prisma

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String    @unique
  emailVerified DateTime?
  image         String?
  password      String?
  role          Role      @default(USER)
  posts         Post[]
  comments      Comment[]
  accounts      Account[]
  sessions      Session[]

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("users")
}

model Post {
  id          String    @id @default(cuid())
  title       String
  slug        String    @unique
  content     String
  excerpt     String?
  published   Boolean   @default(false)
  featured    Boolean   @default(false)
  viewCount   Int       @default(0)

  authorId    String
  author      User      @relation(fields: [authorId], references: [id], onDelete: Cascade)

  comments    Comment[]
  tags        Tag[]

  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  @@map("posts")
}

model Comment {
  id        String   @id @default(cuid())
  content   String

  postId    String
  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)

  authorId  String
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)

  parentId  String?
  parent    Comment? @relation("CommentReplies", fields: [parentId], references: [id])
  replies   Comment[] @relation("CommentReplies")

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("comments")
}

model Tag {
  id    String @id @default(cuid())
  name  String @unique
  posts Post[]

  @@map("tags")
}

enum Role {
  USER
  ADMIN
}
Enter fullscreen mode Exit fullscreen mode

Server Components and Data Fetching

Server Components allow us to fetch data directly on the server, improving performance and SEO:

// app/blog/page.tsx
import { Metadata } from 'next'
import { Suspense } from 'react'
import { getPosts } from '@/lib/posts'
import PostCard from '@/components/PostCard'
import PostCardSkeleton from '@/components/PostCardSkeleton'

export const metadata: Metadata = {
  title: 'Blog Posts | My Blog',
  description: 'Latest blog posts and articles',
}

interface SearchParams {
  page?: string
  tag?: string
  search?: string
}

interface BlogPageProps {
  searchParams: SearchParams
}

export default async function BlogPage({ searchParams }: BlogPageProps) {
  const page = parseInt(searchParams.page || '1')
  const tag = searchParams.tag
  const search = searchParams.search

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="mb-8">
        <h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">
          Latest Blog Posts
        </h1>
        <p className="text-gray-600 dark:text-gray-400">
          Discover insights, tutorials, and thoughts on web development
        </p>
      </div>

      <Suspense fallback={<PostGridSkeleton />}>
        <PostGrid page={page} tag={tag} search={search} />
      </Suspense>
    </div>
  )
}

async function PostGrid({ page, tag, search }: SearchParams & { page: number }) {
  const { posts, totalPages, hasNextPage } = await getPosts({
    page,
    limit: 12,
    tag,
    search,
    published: true
  })

  if (posts.length === 0) {
    return (
      <div className="text-center py-12">
        <h3 className="text-xl text-gray-600 dark:text-gray-400">
          No posts found
        </h3>
      </div>
    )
  }

  return (
    <div className="space-y-8">
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {posts.map((post) => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>

      {totalPages > 1 && (
        <Pagination
          currentPage={page}
          totalPages={totalPages}
          hasNextPage={hasNextPage}
        />
      )}
    </div>
  )
}

function PostGridSkeleton() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      {Array.from({ length: 6 }).map((_, i) => (
        <PostCardSkeleton key={i} />
      ))}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Server Actions for Data Mutations

Server Actions provide a seamless way to handle form submissions and data mutations:

// app/actions/posts.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { generateSlug } from '@/lib/utils'

const CreatePostSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200),
  content: z.string().min(10, 'Content must be at least 10 characters'),
  excerpt: z.string().max(500).optional(),
  tags: z.array(z.string()).optional(),
  published: z.boolean().default(false),
  featured: z.boolean().default(false)
})

export type CreatePostState = {
  errors?: {
    title?: string[]
    content?: string[]
    excerpt?: string[]
    general?: string[]
  }
  message?: string
}

export async function createPost(
  prevState: CreatePostState,
  formData: FormData
): Promise<CreatePostState> {
  // Get current user session
  const session = await auth()

  if (!session?.user?.id) {
    return {
      errors: { general: ['You must be logged in to create a post'] }
    }
  }

  // Validate form data
  const validatedFields = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
    excerpt: formData.get('excerpt'),
    tags: formData.get('tags')?.toString().split(',').filter(Boolean) || [],
    published: formData.get('published') === 'on',
    featured: formData.get('featured') === 'on'
  })

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors
    }
  }

  const { title, content, excerpt, tags, published, featured } = validatedFields.data

  try {
    // Generate unique slug
    const baseSlug = generateSlug(title)
    let slug = baseSlug
    let counter = 1

    while (await prisma.post.findUnique({ where: { slug } })) {
      slug = `${baseSlug}-${counter}`
      counter++
    }

    // Create post with tags
    const post = await prisma.post.create({
      data: {
        title,
        slug,
        content,
        excerpt,
        published,
        featured,
        authorId: session.user.id,
        tags: {
          connectOrCreate: tags.map(tag => ({
            where: { name: tag.toLowerCase().trim() },
            create: { name: tag.toLowerCase().trim() }
          }))
        }
      },
      include: {
        tags: true,
        author: {
          select: {
            name: true,
            email: true,
            image: true
          }
        }
      }
    })

    // Revalidate relevant pages
    revalidatePath('/blog')
    revalidatePath('/admin/posts')
    revalidateTag('posts')

    redirect(`/blog/${post.slug}`)
  } catch (error) {
    console.error('Failed to create post:', error)
    return {
      errors: { general: ['Failed to create post. Please try again.'] }
    }
  }
}

export async function updatePostViews(postId: string) {
  try {
    await prisma.post.update({
      where: { id: postId },
      data: {
        viewCount: {
          increment: 1
        }
      }
    })

    revalidateTag(`post-${postId}`)
  } catch (error) {
    console.error('Failed to update post views:', error)
  }
}

export async function deletePost(postId: string): Promise<{ success: boolean; error?: string }> {
  const session = await auth()

  if (!session?.user?.id) {
    return { success: false, error: 'Unauthorized' }
  }

  try {
    const post = await prisma.post.findUnique({
      where: { id: postId },
      select: { authorId: true, slug: true }
    })

    if (!post) {
      return { success: false, error: 'Post not found' }
    }

    if (post.authorId !== session.user.id && session.user.role !== 'ADMIN') {
      return { success: false, error: 'Unauthorized' }
    }

    await prisma.post.delete({
      where: { id: postId }
    })

    revalidatePath('/blog')
    revalidatePath('/admin/posts')
    revalidateTag('posts')

    return { success: true }
  } catch (error) {
    console.error('Failed to delete post:', error)
    return { success: false, error: 'Failed to delete post' }
  }
}
Enter fullscreen mode Exit fullscreen mode

Client Components for Interactivity

Client Components handle user interactions and maintain client-side state:

// components/PostEditor.tsx
'use client'

import { useFormState } from 'react-dom'
import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'
import { createPost, CreatePostState } from '@/app/actions/posts'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { TagInput } from '@/components/TagInput'
import { MarkdownPreview } from '@/components/MarkdownPreview'

const initialState: CreatePostState = {
  errors: {},
  message: ''
}

interface PostEditorProps {
  initialData?: {
    title: string
    content: string
    excerpt?: string
    tags: string[]
    published: boolean
    featured: boolean
  }
}

export default function PostEditor({ initialData }: PostEditorProps) {
  const [state, formAction] = useFormState(createPost, initialState)
  const [isPending, startTransition] = useTransition()
  const [showPreview, setShowPreview] = useState(false)
  const [content, setContent] = useState(initialData?.content || '')
  const router = useRouter()

  const handleSubmit = (formData: FormData) => {
    startTransition(() => {
      formAction(formData)
    })
  }

  return (
    <div className="max-w-4xl mx-auto p-6">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-3xl font-bold">
          {initialData ? 'Edit Post' : 'Create New Post'}
        </h1>
        <div className="flex items-center space-x-4">
          <Label htmlFor="preview-toggle">Preview</Label>
          <Switch
            id="preview-toggle"
            checked={showPreview}
            onCheckedChange={setShowPreview}
          />
        </div>
      </div>

      {state.errors?.general && (
        <div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded mb-4">
          {state.errors.general.join(', ')}
        </div>
      )}

      <form action={handleSubmit} className="space-y-6">
        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
          <div className="space-y-6">
            <div>
              <Label htmlFor="title">Title *</Label>
              <Input
                id="title"
                name="title"
                defaultValue={initialData?.title}
                placeholder="Enter post title..."
                className={state.errors?.title ? 'border-red-500' : ''}
              />
              {state.errors?.title && (
                <p className="text-red-500 text-sm mt-1">{state.errors.title[0]}</p>
              )}
            </div>

            <div>
              <Label htmlFor="excerpt">Excerpt</Label>
              <Textarea
                id="excerpt"
                name="excerpt"
                defaultValue={initialData?.excerpt}
                placeholder="Brief description of your post..."
                rows={3}
                className={state.errors?.excerpt ? 'border-red-500' : ''}
              />
              {state.errors?.excerpt && (
                <p className="text-red-500 text-sm mt-1">{state.errors.excerpt[0]}</p>
              )}
            </div>

            <div>
              <Label>Tags</Label>
              <TagInput
                name="tags"
                defaultValue={initialData?.tags || []}
                placeholder="Add tags..."
              />
            </div>

            <div className="flex space-x-6">
              <div className="flex items-center space-x-2">
                <Switch
                  id="published"
                  name="published"
                  defaultChecked={initialData?.published}
                />
                <Label htmlFor="published">Published</Label>
              </div>

              <div className="flex items-center space-x-2">
                <Switch
                  id="featured"
                  name="featured"
                  defaultChecked={initialData?.featured}
                />
                <Label htmlFor="featured">Featured</Label>
              </div>
            </div>
          </div>

          <div className="space-y-6">
            <div>
              <Label htmlFor="content">Content *</Label>
              {showPreview ? (
                <div className="border rounded-lg p-4 min-h-[400px] bg-gray-50">
                  <MarkdownPreview content={content} />
                </div>
              ) : (
                <Textarea
                  id="content"
                  name="content"
                  value={content}
                  onChange={(e) => setContent(e.target.value)}
                  placeholder="Write your post content in Markdown..."
                  rows={20}
                  className={`font-mono ${state.errors?.content ? 'border-red-500' : ''}`}
                />
              )}
              {state.errors?.content && (
                <p className="text-red-500 text-sm mt-1">{state.errors.content[0]}</p>
              )}
            </div>
          </div>
        </div>

        <div className="flex justify-end space-x-4">
          <Button
            type="button"
            variant="outline"
            onClick={() => router.back()}
          >
            Cancel
          </Button>
          <Button
            type="submit"
            disabled={isPending}
          >
            {isPending ? 'Saving...' : (initialData ? 'Update Post' : 'Create Post')}
          </Button>
        </div>
      </form>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Authentication with NextAuth.js

// lib/auth.ts
import { NextAuthOptions } from 'next-auth'
import { PrismaAdapter } from '@auth/prisma-adapter'
import CredentialsProvider from 'next-auth/providers/credentials'
import GoogleProvider from 'next-auth/providers/google'
import { compare } from 'bcryptjs'
import { prisma } from './prisma'

export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),
  session: {
    strategy: 'jwt'
  },
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!
    }),
    CredentialsProvider({
      name: 'credentials',
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' }
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null
        }

        const user = await prisma.user.findUnique({
          where: { email: credentials.email }
        })

        if (!user || !user.password) {
          return null
        }

        const isPasswordValid = await compare(credentials.password, user.password)

        if (!isPasswordValid) {
          return null
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
          image: user.image
        }
      }
    })
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role
      }
      return token
    },
    async session({ session, token }) {
      if (token) {
        session.user.id = token.sub!
        session.user.role = token.role as string
      }
      return session
    }
  },
  pages: {
    signIn: '/auth/signin',
    signUp: '/auth/signup'
  }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Features and Optimizations

1. Image Optimization and Upload

// components/ImageUpload.tsx
'use client'

import { useState, useCallback } from 'react'
import Image from 'next/image'
import { useDropzone } from 'react-dropzone'
import { uploadImage } from '@/app/actions/upload'

interface ImageUploadProps {
  onImageUploaded: (url: string) => void
  currentImage?: string
}

export default function ImageUpload({ onImageUploaded, currentImage }: ImageUploadProps) {
  const [isUploading, setIsUploading] = useState(false)
  const [preview, setPreview] = useState<string>(currentImage || '')

  const onDrop = useCallback(async (acceptedFiles: File[]) => {
    const file = acceptedFiles[0]
    if (!file) return

    setIsUploading(true)

    try {
      const formData = new FormData()
      formData.append('file', file)

      const result = await uploadImage(formData)

      if (result.success && result.url) {
        setPreview(result.url)
        onImageUploaded(result.url)
      }
    } catch (error) {
      console.error('Upload failed:', error)
    } finally {
      setIsUploading(false)
    }
  }, [onImageUploaded])

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept: {
      'image/*': ['.jpeg', '.jpg', '.png', '.webp']
    },
    maxSize: 5 * 1024 * 1024, // 5MB
    multiple: false
  })

  return (
    <div className="space-y-4">
      <div
        {...getRootProps()}
        className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors
          ${isDragActive ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}
          ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}
        `}
      >
        <input {...getInputProps()} disabled={isUploading} />

        {preview ? (
          <div className="space-y-4">
            <div className="relative w-full h-48">
              <Image
                src={preview}
                alt="Preview"
                fill
                className="object-cover rounded"
              />
            </div>
            <p className="text-sm text-gray-600">
              Click or drag to replace image
            </p>
          </div>
        ) : (
          <div className="space-y-2">
            <div className="text-gray-400">
              <svg className="mx-auto h-12 w-12" stroke="currentColor" fill="none" viewBox="0 0 48 48">
                <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </div>
            <div>
              <p className="text-lg">
                {isDragActive ? 'Drop image here' : 'Drag & drop an image here'}
              </p>
              <p className="text-sm text-gray-500">
                or click to select (max 5MB)
              </p>
            </div>
          </div>
        )}

        {isUploading && (
          <div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-75 rounded-lg">
            <div className="text-center">
              <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
              <p className="mt-2 text-sm text-gray-600">Uploading...</p>
            </div>
          </div>
        )}
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

2. Real-time Comments with Server Actions

// components/CommentSection.tsx
'use client'

import { useState, useTransition } from 'react'
import { useSession } from 'next-auth/react'
import { addComment, deleteComment } from '@/app/actions/comments'
import { Comment, User } from '@prisma/client'

type CommentWithAuthor = Comment & {
  author: Pick<User, 'name' | 'image'>
  replies: CommentWithAuthor[]
}

interface CommentSectionProps {
  postId: string
  initialComments: CommentWithAuthor[]
}

export default function CommentSection({ postId, initialComments }: CommentSectionProps) {
  const { data: session } = useSession()
  const [comments, setComments] = useState(initialComments)
  const [newComment, setNewComment] = useState('')
  const [replyTo, setReplyTo] = useState<string | null>(null)
  const [isPending, startTransition] = useTransition()

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (!newComment.trim() || !session?.user) return

    startTransition(async () => {
      const result = await addComment({
        content: newComment,
        postId,
        parentId: replyTo
      })

      if (result.success && result.comment) {
        // Optimistically update UI
        if (replyTo) {
          setComments(prev => prev.map(comment => 
            comment.id === replyTo 
              ? { ...comment, replies: [...comment.replies, result.comment!] }
              : comment
          ))
        } else {
          setComments(prev => [result.comment!, ...prev])
        }

        setNewComment('')
        setReplyTo(null)
      }
    })
  }

  return (
    <div className="mt-12 space-y-6">
      <h3 className="text-2xl font-semibold">Comments ({comments.length})</h3>

      {session?.user ? (
        <form onSubmit={handleSubmit} className="space-y-4">
          <textarea
            value={newComment}
            onChange={(e) => setNewComment(e.target.value)}
            placeholder={replyTo ? "Write a reply..." : "Write a comment..."}
            className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            rows={4}
          />
          <div className="flex justify-between">
            {replyTo && (
              <button
                type="button"
                onClick={() => setReplyTo(null)}
                className="text-gray-500 hover:text-gray-700"
              >
                Cancel Reply
              </button>
            )}
            <button
              type="submit"
              disabled={isPending || !newComment.trim()}
              className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
            >
              {isPending ? 'Posting...' : (replyTo ? 'Reply' : 'Comment')}
            </button>
          </div>
        </form>
      ) : (
        <p className="text-gray-600">
          <a href="/auth/signin" className="text-blue-600 hover:underline">
            Sign in
          </a>{' '}
          to join the conversation
        </p>
      )}

      <div className="space-y-6">
        {comments.map((comment) => (
          <CommentItem
            key={comment.id}
            comment={comment}
            onReply={setReplyTo}
            currentUserId={session?.user?.id}
          />
        ))}
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

3. SEO and Metadata Optimization

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPostBySlug } from '@/lib/posts'
import { updatePostViews } from '@/app/actions/posts'
import PostContent from '@/components/PostContent'
import CommentSection from '@/components/CommentSection'

interface PostPageProps {
  params: {
    slug: string
  }
}

export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
  const post = await getPostBySlug(params.slug)

  if (!post) {
    return {
      title: 'Post Not Found'
    }
  }

  return {
    title: post.title,
    description: post.excerpt || `Read ${post.title} by ${post.author.name}`,
    authors: [{ name: post.author.name! }],
    openGraph: {
      title: post.title,
      description: post.excerpt || `Read ${post.title} by ${post.author.name}`,
      type: 'article',
      publishedTime: post.createdAt.toISOString(),
      modifiedTime: post.updatedAt.toISOString(),
      authors: [post.author.name!],
      tags: post.tags.map(tag => tag.name)
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt || `Read ${post.title} by ${post.author.name}`,
    },
    keywords: post.tags.map(tag => tag.name).join(', ')
  }
}

export default async function PostPage({ params }: PostPageProps) {
  const post = await getPostBySlug(params.slug, {
    includeComments: true,
    includeUnpublished: false
  })

  if (!post) {
    notFound()
  }

  // Update view count (fire and forget)
  updatePostViews(post.id)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.excerpt,
    author: {
      '@type': 'Person',
      name: post.author.name
    },
    datePublished: post.createdAt.toISOString(),
    dateModified: post.updatedAt.toISOString(),
    keywords: post.tags.map(tag => tag.name).join(', ')
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article className="max-w-4xl mx-auto px-4 py-8">
        <PostContent post={post} />
        <CommentSection 
          postId={post.id} 
          initialComments={post.comments} 
        />
      </article>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Performance Optimizations and Best Practices

1. Streaming and Loading UI


typescript
// app/blog/loading.tsx
export default function Loading()
Enter fullscreen mode Exit fullscreen mode

Top comments (0)