Oct 15, 2025

From Vue to Next.js - Changing the Tool, Not the Mindset

vuenextjsfrontendfullstacklearningarchitecturespringbootseoopengraph
From Vue to Next.js - Changing the Tool, Not the Mindset

🧭 From Vue to Next.js - Changing the Tool, Not the Mindset

When I started building DanceNowPortal in 2024, I was primarily a backend developer transitioning into full-stack work. The project started as a simple admin panel but grew into a comprehensive platform handling hundreds of users, real-time voting, detailed reporting and fee calculations, and integrations with third-party services.

Vue felt like the perfect entry point. It was approachable, fast to learn, and it gave me visible results quickly. With Vuetify, I could design full screens without worrying too much about CSS details, and that helped me stay focused on functionality. At that stage, speed mattered more than scalability - and Vue delivered exactly that.


When the project grows beyond the plan

Over time, DanceNowPortal became much more than I initially imagined. Admin panels, uploads, reports, real-time voting, Google Drive integration, and booking systems - all handled within one application.

That's when I started to notice the limits. Not in Vue itself, but in my setup. I had routing through Vue Router, but I missed the higher-level structure that Nuxt would have provided - built-in SSR, SEO handling, and convention-based organization.

But it wasn't just about missing features - I hit real production problems that needed creative solutions. Let me show you the biggest one.


The Open Graph Problem

One day, I tried sharing a competition link on Viber and LinkedIn. Instead of showing the competition name and image, it displayed only my generic site title and logo. Every single link looked the same.

The problem was simple but frustrating: crawlers don't execute JavaScript. My Vue SPA would inject meta tags dynamically on the client side, but by that time, the crawler had already captured the page. To them, every page looked identical - just an empty HTML shell with a generic title.

What I needed

  • Dynamic Open Graph previews per competition and language
  • Something that works within my existing production architecture
  • A solution that doesn't require weeks of refactoring for a live application

At that time, migrating to Nuxt would have meant weeks of refactoring for a project already serving users in production. The pragmatic choice was to solve the immediate problem within my existing architecture.

The solution: A minimal HTML microservice

I built a small Spring Boot controller that generates a minimal HTML page with proper OG tags. The trick: crawlers get the HTML with meta tags, while real users are instantly redirected to the Vue SPA.

The flow:

  1. Vue generates share links like /l/competition/{id}/{lang}/{ver}
  2. Nginx detects the path and proxies to Spring Boot
  3. Spring Boot returns HTML with OG tags + a meta-refresh redirect to the SPA route
  4. Crawlers capture the tags; humans see the normal Vue page

Here's the controller:

@RestController
@RequestMapping("/og/share")
public class ShareOgController {
 
  @Value("${app.base-url}")
  private String baseUrl;
 
  @Value("${app.og-fallback-image:/ogImage.jpg}")
  private String fallbackImage;
 
  @GetMapping("/competition/{id}/{lang}/{ver}")
  public ResponseEntity<String> share(@PathVariable Long id, @PathVariable String lang, @PathVariable String ver) {
      var c = competitionRepository.findById(id).orElseThrow();
 
      var title = escape(c.getName());
      var absImg = toAbsoluteUrl(baseUrl, c.getImageLink());
      var imageForOg = absImg + "?v=" + id;
      var sharedUrl = toAbsoluteUrl(baseUrl, "/l/competition/" + id + "/" + lang + "/" + ver);
      var spaUrl = toAbsoluteUrl(baseUrl, "/competition-preview/" + id + "/" + lang);
 
      var html = """
        <!doctype html><html><head>
        <meta charset='utf-8'/>
        <title>%s</title>
        <meta property='og:title' content='%s'/>
        <meta property='og:url' content='%s'/>
        <meta property='og:image' content='%s'/>
        <meta property='og:site_name' content='DanceNow Portal'/>
        <meta name='twitter:card' content='summary_large_image'/>
        <meta http-equiv='refresh' content='0;url=%s'/>
        </head><body><script>location.replace('%s');</script></body></html>
      """.formatted(title, title, sharedUrl, imageForOg, spaUrl, spaUrl);
 
      var headers = new HttpHeaders();
      headers.add(HttpHeaders.CONTENT_TYPE, "text/html; charset=UTF-8");
      headers.setCacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic());
      return ResponseEntity.ok().headers(headers).body(html);
  }
}

And the Nginx configuration to route bot traffic:

# Bot detection
map $http_user_agent $is_social_bot {
    default 0;
    ~*facebookexternalhit 1;
    ~*Facebot 1;
    ~*Twitterbot 1;
    ~*LinkedInBot 1;
    ~*WhatsApp 1;
    ~*Viber 1;
}
 
# Share link handler
location ~ ^/l/competition/([0-9]+)/(sr|en)/([A-Za-z0-9_-]+)/?$ {
    set $comp_id $1;
    set $comp_lang $2;
    set $comp_ver $3;
 
    proxy_pass http://127.0.0.1:8082/og/share/competition/$comp_id/$comp_lang/$comp_ver;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    add_header X-Robots-Tag "noindex, nofollow";
}

Did it work?

Yes. Within a week, sharing competition links on social media started showing proper previews. User engagement on shared links improved noticeably, and the solution has been running reliably in production for over a year with minimal maintenance.

Crawlers got valid OG tags immediately, and users were redirected seamlessly into the SPA.

But here's the thing: this solution worked, and it solved my immediate problem. However, I was essentially building my own SSR layer on the backend just to handle meta tags. Every time I needed dynamic metadata, I had to add another Spring Boot endpoint, another Nginx rule, more manual coordination between systems.

It was functional and pragmatic, but it made me realize something important - for future projects, I wanted these things built-in, not bolted on.


What I really learned

This OG workaround was a turning point. It wasn't about Vue being "bad" - it was about recognizing what my workflow needed going forward.

Other things I started noticing:

  • Vuetify felt heavy - The framework was great for rapid prototyping, but as I gained more frontend confidence, I wanted the flexibility of Tailwind CSS
  • Community resources - When I hit problems, React/Next.js had far more examples, integrations, and Stack Overflow answers
  • SEO limitations - Beyond OG tags, I needed better control over indexing, sitemaps, and page-level optimization
  • Structure and conventions - I wanted the opinionated structure that frameworks like Nuxt or Next.js provide - file-based routing, automatic code splitting, API routes

The shift wasn't a statement that Vue is "worse" - it was simply recognizing that for new projects, my priorities had changed.


My transition to Next.js

Important clarification: DanceNowPortal is still running on Vue in production, and that's fine. The project works, users are happy, and migrating such a large codebase would be a massive undertaking with little immediate benefit.

But for new projects - including this very website you're reading - I switched to Next.js. Not because I regret Vue, but because I now prioritize:

  • SSR out of the box - No more backend workarounds for meta tags
  • Type safety everywhere - Shared TypeScript between frontend and backend
  • Modern DX - Server components, server actions, streaming
  • Ecosystem maturity - More libraries, more examples, more community solutions

Here's how the same OG problem looks in Next.js:

// app/competition/[id]/page.tsx
export async function generateMetadata({ params }: { params: { id: string } }) {
  const competition = await fetchCompetition(params.id);
  
  return {
    title: competition.name,
    openGraph: {
      title: competition.name,
      images: [competition.imageUrl],
      url: `/competition/${params.id}`,
    },
  };
}

No Spring Boot controller. No Nginx routing. No meta-refresh hacks. Just a function that runs on the server and returns the metadata. That's the difference.


Side-by-side comparison

ChallengeVue SPA SolutionNext.js Solution
OG TagsSpring Boot endpoint + Nginx routinggenerateMetadata() function
SSRManual backend renderingBuilt-in
API RoutesSeparate Spring Boot endpoints/app/api/* files
Code SplittingManual configurationAutomatic
Type SafetySeparate frontend/backend typesShared TypeScript types

🧭 Looking Ahead

Today, I work in both worlds:

  • DanceNowPortal (Vue + Spring Boot) - in production since 2024, serving hundreds of active users monthly, with real-time judging, program management, and detailed reporting and fee calculations.
  • New projects (Next.js) — this website, client work, and experiments with modern tooling (SSR, React Server Components, Server Actions).

What I value most from this journey is learning to choose the right tool for the context. Sometimes that’s a pragmatic backend workaround; sometimes it’s adopting a new framework. The key is knowing when to invest in migration versus when to ship a working solution.

Vue taught me how to build confidently and deliver quickly. Next.js gives me the structure I now prefer for long-term projects. Neither choice was wrong - they were right for different stages of my journey.


If you're in a similar position

If you have a working Vue SPA but are considering Next.js for future work - here's my advice:

Don't rush to migrate existing projects. If it works, let it work. But do experiment with Next.js on something new. Build a side project, a personal site, a small client tool. See if the DX resonates with you.

The real lesson here: good code is not just about syntax or framework, but about choosing what fits your current needs best. My needs evolved, and so did my tools. Yours might too - or they might not. And that's perfectly fine.

If you're building production applications and need someone who can navigate both legacy systems and modern stacks, let's talk.