Skip to content

Blog Tutorial: Styling ​

In this chapter, we'll enhance our blog's appearance using Tailwind CSS.

Install Tailwind CSS ​

bash
bun add tailwindcss postcss autoprefixer
bunx tailwindcss init -p

Configure Tailwind ​

Update tailwind.config.js:

js
/** @type {import('tailwindcss').Config} */
export default {
  content: [
    './app/**/*.{js,ts,jsx,tsx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Create Tailwind Entry ​

Replace public/styles.css with:

css
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .btn {
    @apply inline-flex items-center justify-center px-4 py-2
           bg-blue-600 text-white font-medium rounded-lg
           hover:bg-blue-700 focus:outline-none focus:ring-2
           focus:ring-blue-500 focus:ring-offset-2
           disabled:opacity-50 disabled:cursor-not-allowed
           transition-colors;
  }

  .btn-secondary {
    @apply bg-gray-600 hover:bg-gray-700 focus:ring-gray-500;
  }

  .btn-danger {
    @apply bg-red-600 hover:bg-red-700 focus:ring-red-500;
  }

  .input {
    @apply w-full px-3 py-2 border border-gray-300 rounded-lg
           focus:outline-none focus:ring-2 focus:ring-blue-500
           focus:border-transparent
           disabled:bg-gray-100 disabled:cursor-not-allowed;
  }

  .label {
    @apply block text-sm font-medium text-gray-700 mb-1;
  }

  .error-text {
    @apply text-sm text-red-600 mt-1;
  }

  .card {
    @apply bg-white rounded-lg border border-gray-200 p-6
           hover:shadow-md transition-shadow;
  }
}

Update Layout ​

Update app/routes/_layout.tsx:

tsx
// app/routes/_layout.tsx
import { Link, NavLink } from '@ereo/client'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>My Blog</title>
        <link rel="stylesheet" href="/styles.css" />
      </head>
      <body className="bg-gray-50 text-gray-900 min-h-screen">
        <header className="bg-white border-b border-gray-200 sticky top-0 z-50">
          <nav className="max-w-4xl mx-auto px-4 h-16 flex items-center justify-between">
            <Link href="/" className="text-xl font-bold text-gray-900 hover:text-blue-600">
              My Blog
            </Link>

            <div className="flex items-center gap-6">
              <NavLink
                href="/"
                end
                className={({ isActive }) =>
                  `text-sm font-medium ${isActive ? 'text-blue-600' : 'text-gray-600 hover:text-gray-900'}`
                }
              >
                Home
              </NavLink>
              <NavLink
                href="/posts"
                className={({ isActive }) =>
                  `text-sm font-medium ${isActive ? 'text-blue-600' : 'text-gray-600 hover:text-gray-900'}`
                }
              >
                Posts
              </NavLink>
              <Link href="/posts/new" className="btn text-sm">
                Write Post
              </Link>
            </div>
          </nav>
        </header>

        <main className="max-w-4xl mx-auto px-4 py-8">
          {children}
        </main>

        <footer className="border-t border-gray-200 mt-16">
          <div className="max-w-4xl mx-auto px-4 py-8 text-center text-gray-500 text-sm">
            Built with EreoJS
          </div>
        </footer>
      </body>
    </html>
  )
}

Update Home Page ​

tsx
// app/routes/index.tsx
import { createLoader } from '@ereo/data'
import { Link } from '@ereo/client'
import { getPosts } from '~/lib/db'

export const loader = createLoader(async () => {
  const posts = getPosts().slice(0, 3)
  return { posts }
})

export default function Home({ loaderData }: { loaderData: any }) {
  const { posts } = loaderData

  return (
    <div>
      {/* Hero */}
      <section className="text-center py-16">
        <h1 className="text-4xl font-bold text-gray-900 mb-4">
          Welcome to My Blog
        </h1>
        <p className="text-xl text-gray-600 max-w-2xl mx-auto">
          Thoughts on web development, React, and building modern applications
          with EreoJS.
        </p>
      </section>

      {/* Recent Posts */}
      <section>
        <div className="flex items-center justify-between mb-6">
          <h2 className="text-2xl font-bold text-gray-900">Recent Posts</h2>
          <Link href="/posts" className="text-blue-600 hover:text-blue-800 font-medium">
            View all →
          </Link>
        </div>

        <div className="space-y-4">
          {posts.map((post: any) => (
            <article key={post.id} className="card">
              <Link href={`/posts/${post.slug}`}>
                <h3 className="text-xl font-semibold text-gray-900 hover:text-blue-600 mb-2">
                  {post.title}
                </h3>
              </Link>
              <p className="text-sm text-gray-500 mb-3">
                {new Date(post.created_at).toLocaleDateString('en-US', {
                  year: 'numeric',
                  month: 'long',
                  day: 'numeric'
                })}
              </p>
              <p className="text-gray-600">
                {post.excerpt}
              </p>
            </article>
          ))}
        </div>

        {posts.length === 0 && (
          <div className="text-center py-12 text-gray-500">
            <p>No posts yet.</p>
            <Link href="/posts/new" className="text-blue-600 hover:underline">
              Write your first post →
            </Link>
          </div>
        )}
      </section>
    </div>
  )
}

Update Posts List ​

tsx
// app/routes/posts/index.tsx
import { createLoader } from '@ereo/data'
import { Link } from '@ereo/client'
import { getPosts } from '~/lib/db'

export const loader = createLoader(async () => {
  const posts = getPosts()
  return { posts }
})

export default function Posts({ loaderData }: { loaderData: any }) {
  const { posts } = loaderData

  return (
    <div>
      <div className="flex items-center justify-between mb-8">
        <h1 className="text-3xl font-bold text-gray-900">All Posts</h1>
        <Link href="/posts/new" className="btn">
          New Post
        </Link>
      </div>

      {posts.length === 0 ? (
        <div className="text-center py-12">
          <div className="text-gray-400 text-5xl mb-4">📝</div>
          <p className="text-gray-500 mb-4">No posts yet.</p>
          <Link href="/posts/new" className="btn">
            Write your first post
          </Link>
        </div>
      ) : (
        <div className="space-y-4">
          {posts.map((post: any) => (
            <article key={post.id} className="card group">
              <div className="flex items-start justify-between">
                <div className="flex-1">
                  <Link href={`/posts/${post.slug}`}>
                    <h2 className="text-xl font-semibold text-gray-900 group-hover:text-blue-600 mb-2">
                      {post.title}
                    </h2>
                  </Link>
                  <p className="text-sm text-gray-500 mb-2">
                    {new Date(post.created_at).toLocaleDateString('en-US', {
                      year: 'numeric',
                      month: 'long',
                      day: 'numeric'
                    })}
                    {post.likes > 0 && (
                      <span className="ml-3">❤️ {post.likes}</span>
                    )}
                  </p>
                  <p className="text-gray-600">{post.excerpt}</p>
                </div>
                <Link
                  href={`/posts/${post.slug}`}
                  className="text-blue-600 hover:text-blue-800 font-medium ml-4 shrink-0"
                >
                  Read →
                </Link>
              </div>
            </article>
          ))}
        </div>
      )}
    </div>
  )
}

Update Post Detail ​

tsx
// Update the post detail page with Tailwind classes
// app/routes/posts/[slug].tsx

export default function PostPage({ loaderData }: { loaderData: any }) {
  const { post, comments } = loaderData
  const actionData = useActionData()
  const navigation = useNavigation()
  const isSubmitting = navigation.status === 'submitting'

  return (
    <article className="max-w-3xl mx-auto">
      <header className="mb-8">
        <h1 className="text-4xl font-bold text-gray-900 mb-4">{post.title}</h1>
        <div className="flex items-center gap-4 text-gray-500">
          <time>
            {new Date(post.created_at).toLocaleDateString('en-US', {
              year: 'numeric',
              month: 'long',
              day: 'numeric'
            })}
          </time>
          <span>·</span>
          <Link
            href={`/posts/${post.slug}/edit`}
            className="text-blue-600 hover:text-blue-800"
          >
            Edit
          </Link>
        </div>
      </header>

      <div className="prose prose-lg max-w-none mb-12">
        {post.content.split('\n').map((paragraph: string, i: number) => (
          <p key={i} className="mb-4 text-gray-700 leading-relaxed">
            {paragraph}
          </p>
        ))}
      </div>

      {/* Like Button */}
      <div className="mb-12">
        <LikeButton client:load postId={post.id} initialLikes={post.likes || 0} />
      </div>

      {/* Comments Section */}
      <section className="border-t border-gray-200 pt-8">
        <h2 className="text-2xl font-bold text-gray-900 mb-6">
          Comments ({comments.length})
        </h2>

        {/* Comment Form */}
        <div className="bg-gray-50 rounded-lg p-6 mb-8">
          <h3 className="font-semibold text-gray-900 mb-4">Leave a Comment</h3>

          {actionData?.success && (
            <div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-4">
              Comment posted successfully!
            </div>
          )}

          <Form method="post" className="space-y-4">
            <div>
              <label htmlFor="author" className="label">Name</label>
              <input
                type="text"
                id="author"
                name="author"
                className="input"
                defaultValue={actionData?.success ? '' : actionData?.values?.author}
                disabled={isSubmitting}
                required
              />
              {actionData?.errors?.author && (
                <p className="error-text">{actionData.errors.author}</p>
              )}
            </div>

            <div>
              <label htmlFor="content" className="label">Comment</label>
              <textarea
                id="content"
                name="content"
                rows={4}
                className="input"
                defaultValue={actionData?.success ? '' : actionData?.values?.content}
                disabled={isSubmitting}
                required
              />
              {actionData?.errors?.content && (
                <p className="error-text">{actionData.errors.content}</p>
              )}
            </div>

            <button type="submit" className="btn" disabled={isSubmitting}>
              {isSubmitting ? 'Posting...' : 'Post Comment'}
            </button>
          </Form>
        </div>

        {/* Comments List */}
        {comments.length === 0 ? (
          <p className="text-gray-500 text-center py-8">
            No comments yet. Be the first!
          </p>
        ) : (
          <div className="space-y-4">
            {comments.map((comment: any) => (
              <div key={comment.id} className="bg-white border border-gray-200 rounded-lg p-4">
                <div className="flex items-center justify-between mb-2">
                  <span className="font-medium text-gray-900">{comment.author}</span>
                  <time className="text-sm text-gray-500">
                    {new Date(comment.created_at).toLocaleDateString()}
                  </time>
                </div>
                <p className="text-gray-700">{comment.content}</p>
              </div>
            ))}
          </div>
        )}
      </section>

      <div className="mt-12 pt-8 border-t border-gray-200">
        <Link href="/posts" className="text-blue-600 hover:text-blue-800 font-medium">
          ← Back to all posts
        </Link>
      </div>
    </article>
  )
}

Build CSS ​

Add a build script to your package.json:

json
{
  "scripts": {
    "dev": "ereo dev",
    "build": "tailwindcss -i ./public/styles.css -o ./public/styles.css --minify && ereo build"
  }
}

What We've Done ​

  1. Installed and configured Tailwind CSS
  2. Created reusable component classes
  3. Updated all pages with modern styling
  4. Added responsive design
  5. Improved visual hierarchy

Next Step ​

In the final chapter, we'll deploy our blog to production.

← Previous: Forms | Continue to Chapter 6: Deployment →

Released under the MIT License.