From Zero to Deployed: Building a Professional Website with Next.js and Tailwind
Building webceive.com was more than just creating a website - it was about establishing credibility, demonstrating technical expertise, and creating a foundation for our business. Here's the complete breakdown of how we went from blank screen to production deployment.
The Requirements
Before writing any code, we defined what success looked like:
- Professional appearance that builds trust
- Mobile-first responsive design
- Sub-1-second load times
- Dark/light mode support
- Contact form with validation
- Blog system for content marketing
- SEO optimization
- Accessibility compliance
The Tech Stack Decision
Framework: Next.js 15
- App Router for modern routing
- Built-in optimization (images, fonts, etc.)
- Static export for CDN deployment
- Server-side rendering for SEO
Styling: Tailwind CSS + shadcn/ui
- Utility-first CSS for rapid development
- shadcn/ui for consistent component library
- Custom design system with CSS variables
- Dark mode support out of the box
Deployment: Docker + nginx
- Self-hosted for complete control
- Static export served by nginx
- SSL termination and caching
- Zero vendor lock-in
Project Architecture
src/
├── app/ # Next.js App Router
│ ├── globals.css # Global styles and CSS variables
│ ├── layout.tsx # Root layout with providers
│ ├── page.tsx # Homepage
│ ├── blog/ # Blog system
│ └── contact/ # Contact page
├── components/ # Reusable components
│ ├── ui/ # shadcn/ui components
│ ├── navbar.tsx # Navigation
│ └── contact-form.tsx # Contact form
├── data/ # Static content
└── lib/ # Utilities
Component Development Strategy
1. Design System First
We started with a cohesive design system in globals.css:
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
/* ... more CSS variables ... */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... dark mode overrides ... */
}
This approach gave us consistent theming across all components.
2. Component Library with shadcn/ui
Instead of building UI components from scratch, we used shadcn/ui:
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add input
npx shadcn@latest add form
Each component comes with:
- TypeScript definitions
- Accessibility features
- Customizable styling
- Dark mode support
3. Mobile-First Navigation
The navigation component handles both desktop and mobile:
export default function Navbar() {
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/80 backdrop-blur-xl">
<div className="container flex h-16 items-center justify-between">
<Logo />
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-8">
<NavLinks />
</nav>
{/* Mobile Navigation */}
<Sheet>
<SheetTrigger asChild className="md:hidden">
<Button variant="ghost" size="sm">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent>
<MobileNavLinks />
</SheetContent>
</Sheet>
<ThemeToggle />
</div>
</header>
);
}
Key features:
- Sticky positioning with backdrop blur
- Responsive design (hidden/shown based on screen size)
- Theme toggle for dark/light mode
- Mobile-friendly slide-out menu
Form Development with Validation
The contact form needed to be bulletproof. We used react-hook-form with Zod validation:
const formSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email address"),
company: z.string().optional(),
phone: z.string().optional(),
project: z.string().min(10, "Please tell us more about your project"),
});
export default function ContactForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
email: "",
company: "",
phone: "",
project: "",
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
// Form submission logic
console.log(values);
} catch (error) {
console.error("Form submission error:", error);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Form fields with proper validation and error handling */}
</form>
</Form>
);
}
This approach provided:
- Client-side validation with instant feedback
- Type-safe form handling
- Consistent error messaging
- Accessibility compliance
Performance Optimization
1. Static Export Configuration
Next.js configuration for optimal static export:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
trailingSlash: true,
images: {
unoptimized: true,
},
experimental: {
optimizeCss: true,
},
};
module.exports = nextConfig;
2. Image Optimization
All images optimized using Next.js Image component:
import Image from 'next/image';
<Image
src="/hero-image.jpg"
alt="Professional website development"
width={800}
height={600}
priority
className="rounded-lg"
/>
3. Font Optimization
Custom fonts loaded efficiently:
import { Inter, Roboto_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
});
Blog System Implementation
1. Dynamic Routing
Created dynamic routes for blog posts:
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = getBlogPost(slug);
if (!post) {
notFound();
}
return (
<article className="prose prose-neutral dark:prose-invert max-w-4xl mx-auto">
<h1>{post.title}</h1>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<time>{post.date}</time>
<span>{post.readTime}</span>
</div>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
2. Blog Listing Page
Main blog page with filtering and search:
export default function BlogPage() {
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState("all");
const filteredPosts = blogPosts.filter(post => {
const matchesSearch = post.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
post.summary.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = selectedCategory === "all" || post.category === selectedCategory;
return matchesSearch && matchesCategory;
});
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<CategoryFilter selected={selectedCategory} onChange={setSelectedCategory} />
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{filteredPosts.map(post => (
<BlogCard key={post.id} post={post} />
))}
</div>
</div>
);
}
Testing Strategy
1. Component Testing with Jest
import { render, screen } from '@testing-library/react';
import ContactForm from '../contact-form';
describe('ContactForm', () => {
it('renders all form fields', () => {
render(<ContactForm />);
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/project/i)).toBeInTheDocument();
});
it('shows validation errors for invalid input', async () => {
render(<ContactForm />);
const submitButton = screen.getByRole('button', { name: /send message/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/name must be at least 2 characters/i)).toBeInTheDocument();
});
});
});
2. E2E Testing with Cypress
describe('Website Navigation', () => {
it('should navigate between pages correctly', () => {
cy.visit('/');
// Test navigation links
cy.get('[data-testid="nav-about"]').click();
cy.url().should('include', '/about');
// Test contact form
cy.get('[data-testid="nav-contact"]').click();
cy.get('[data-testid="contact-form"]').should('be.visible');
// Test blog
cy.get('[data-testid="nav-blog"]').click();
cy.get('[data-testid="blog-posts"]').should('be.visible');
});
});
Deployment Configuration
1. Docker Setup
FROM nginx:alpine
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Copy built website
COPY out/ /usr/share/nginx/html/
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/ || exit 1
EXPOSE 80
2. Build Process
#!/bin/bash
echo "Building webceive.com..."
# Install dependencies
npm ci
# Run tests
npm run test
# Build for production
npm run build
# Create Docker image
docker build -t webceive-website .
# Deploy to production
docker-compose up -d
3. nginx Configuration
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Handle Next.js routing
location / {
try_files $uri $uri.html $uri/ =404;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
Performance Results
After optimization:
- Lighthouse Score: 98/100
- First Contentful Paint: 0.6s
- Largest Contentful Paint: 0.9s
- Time to Interactive: 1.1s
- Total Bundle Size: 89KB gzipped
Lessons Learned
What Worked Well
- shadcn/ui components - Saved weeks of development time
- Tailwind CSS - Rapid prototyping and consistent styling
- Static export - Lightning-fast load times
- Docker deployment - Reproducible and scalable
What We'd Do Differently
- Start with testing - Added tests later, should have been TDD
- Component documentation - Storybook stories from day one
- Performance budget - Set limits earlier in development
- Accessibility audit - Should have been part of the build process
The Business Impact
The website became more than just marketing material:
- Client confidence - Technical competence demonstrated through code quality
- Cost savings - $15,000 saved vs. agency development
- Ongoing control - No vendor dependencies for changes
- Technical credibility - Clients trust us with their technical decisions
Code Repository Structure
Final project structure:
webceive-website/
├── src/ # Source code
├── public/ # Static assets
├── stories/ # Storybook stories
├── cypress/ # E2E tests
├── docker-compose.yml # Local development
├── Dockerfile # Production build
├── nginx.conf # Web server config
└── package.json # Dependencies and scripts
Next Steps
With the foundation complete, we're now working on:
- Advanced analytics and conversion tracking
- A/B testing framework for optimization
- Automated content generation for the blog
- Progressive Web App features
- Advanced SEO optimization
The website is live at webceive.com - a testament to what's possible with modern web technologies and attention to detail.
Next: Self-hosting vs SaaS: Our Infrastructure Decisions for a Bootstrapped Startup